feat: Model Provider and Model config

This commit is contained in:
shine1i 2026-02-05 12:20:11 +01:00
commit 7350e52f2f
17 changed files with 825 additions and 57 deletions

View file

@ -9,6 +9,8 @@ import {
FunctionIcon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
Shield02Icon,
Tag01Icon,
TagsIcon,
UserAccountIcon,
@ -18,10 +20,14 @@ import type { LlmType, NodeConfig, SamplerConfig, SamplerType } from "../types";
import {
makeExpressionConfig,
makeLlmConfig,
makeModelConfig,
makeModelProviderConfig,
makeSamplerConfig,
} from "../utils";
import { ExpressionDialog } from "../dialogs/expression/expression-dialog";
import { LlmDialog } from "../dialogs/llm/llm-dialog";
import { ModelConfigDialog } from "../dialogs/models/model-config-dialog";
import { ModelProviderDialog } from "../dialogs/models/model-provider-dialog";
import { CategoryDialog } from "../dialogs/samplers/category-dialog";
import { DatetimeDialog } from "../dialogs/samplers/datetime-dialog";
import { GaussianDialog } from "../dialogs/samplers/gaussian-dialog";
@ -31,7 +37,12 @@ import { UniformDialog } from "../dialogs/samplers/uniform-dialog";
import { UuidDialog } from "../dialogs/samplers/uuid-dialog";
export type BlockKind = "sampler" | "llm" | "expression";
export type BlockType = SamplerType | LlmType | "expression";
export type BlockType =
| SamplerType
| LlmType
| "expression"
| "model_provider"
| "model_config";
type IconType = typeof CodeIcon;
@ -249,6 +260,36 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
/>
) : null,
},
{
kind: "llm",
type: "model_provider",
title: "Model Provider",
description: "Configure API endpoint + key.",
icon: Shield02Icon,
createConfig: (id, existing) => makeModelProviderConfig(id, existing),
renderDialog: ({ config, onUpdate }) =>
config.kind === "model_provider" ? (
<ModelProviderDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
},
{
kind: "llm",
type: "model_config",
title: "Model Config",
description: "Alias + model + inference params.",
icon: Plant01Icon,
createConfig: (id, existing) => makeModelConfig(id, existing),
renderDialog: ({ config, onUpdate }) =>
config.kind === "model_config" ? (
<ModelConfigDialog
config={config}
onUpdate={(patch) => onUpdate(config.id, patch)}
/>
) : null,
},
{
kind: "expression",
type: "expression",
@ -297,6 +338,12 @@ export function getBlockDefinitionForConfig(
if (config.kind === "llm") {
return getBlockDefinition("llm", config.llm_type);
}
if (config.kind === "model_provider") {
return getBlockDefinition("llm", "model_provider");
}
if (config.kind === "model_config") {
return getBlockDefinition("llm", "model_config");
}
return getBlockDefinition("expression", "expression");
}

View file

@ -29,7 +29,7 @@ import { importCanvasPayload } from "./utils/import";
import { buildCanvasPayload } from "./utils/payload";
const NODE_TYPES: NodeTypes = { builder: CanvasNode };
const EDGE_TYPES: EdgeTypes = { canvas: CanvasEdge };
const EDGE_TYPES: EdgeTypes = { canvas: CanvasEdge, semantic: CanvasEdge };
type LayoutControlsProps = {
direction: "LR" | "TB";
@ -77,6 +77,8 @@ export function CanvasLabPage(): ReactElement {
onConnect,
addSamplerNode,
addLlmNode,
addModelProviderNode,
addModelConfigNode,
addExpressionNode,
openConfig,
updateConfig,
@ -100,6 +102,8 @@ export function CanvasLabPage(): ReactElement {
onConnect: state.onConnect,
addSamplerNode: state.addSamplerNode,
addLlmNode: state.addLlmNode,
addModelProviderNode: state.addModelProviderNode,
addModelConfigNode: state.addModelConfigNode,
addExpressionNode: state.addExpressionNode,
openConfig: state.openConfig,
updateConfig: state.updateConfig,
@ -306,6 +310,8 @@ export function CanvasLabPage(): ReactElement {
onViewChange={setSheetView}
onAddSampler={addSamplerNode}
onAddLlm={addLlmNode}
onAddModelProvider={addModelProviderNode}
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
/>
</Panel>

View file

@ -25,6 +25,8 @@ type BlockSheetProps = {
onViewChange: (view: SheetView) => void;
onAddSampler: (type: SamplerType) => void;
onAddLlm: (type: LlmType) => void;
onAddModelProvider: () => void;
onAddModelConfig: () => void;
onAddExpression: () => void;
};
@ -92,6 +94,8 @@ export function BlockSheet({
onViewChange,
onAddSampler,
onAddLlm,
onAddModelProvider,
onAddModelConfig,
onAddExpression,
}: BlockSheetProps): ReactElement {
const title = getSheetTitle(view);
@ -157,7 +161,13 @@ export function BlockSheet({
if (item.kind === "sampler") {
onAddSampler(item.type as SamplerType);
} else if (item.kind === "llm") {
onAddLlm(item.type as LlmType);
if (item.type === "model_provider") {
onAddModelProvider();
} else if (item.type === "model_config") {
onAddModelConfig();
} else {
onAddLlm(item.type as LlmType);
}
} else {
onAddExpression();
}

View file

@ -10,6 +10,7 @@ export const CanvasEdge = memo(function CanvasEdge({
sourcePosition,
targetPosition,
style,
type,
}: EdgeProps): JSX.Element {
const [path] = getSmoothStepPath({
sourceX,
@ -22,5 +23,10 @@ export const CanvasEdge = memo(function CanvasEdge({
offset: 16,
});
return <BaseEdge id={id} path={path} style={style} />;
const nextStyle =
type === "semantic"
? { ...style, strokeDasharray: "4 4" }
: style;
return <BaseEdge id={id} path={path} style={nextStyle} />;
});

View file

@ -10,6 +10,8 @@ import {
FunctionIcon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
Shield02Icon,
Tag01Icon,
TagsIcon,
UserAccountIcon,
@ -36,6 +38,12 @@ const NODE_META = {
expression: {
tone: "bg-sky-50 text-sky-600 border-sky-100",
},
model_provider: {
tone: "bg-amber-50 text-amber-600 border-amber-100",
},
model_config: {
tone: "bg-indigo-50 text-indigo-600 border-indigo-100",
},
} as const;
const SAMPLER_ICONS: Record<SamplerType, IconType> = {
@ -69,6 +77,12 @@ function resolveNodeIcon(
if (kind === "expression") {
return FunctionIcon;
}
if (kind === "model_provider") {
return Shield02Icon;
}
if (kind === "model_config") {
return Plant01Icon;
}
return DiceFaces03Icon;
}

View file

@ -0,0 +1,121 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import type { ReactElement } from "react";
import type { ModelConfig } from "../../types";
import { useCanvasLabStore } from "../../stores/canvas-lab";
import { NameField } from "../shared/name-field";
type ModelConfigDialogProps = {
config: ModelConfig;
onUpdate: (patch: Partial<ModelConfig>) => void;
};
export function ModelConfigDialog({
config,
onUpdate,
}: ModelConfigDialogProps): ReactElement {
const providerOptions = useCanvasLabStore((state) =>
Object.values(state.configs)
.filter((item) => item.kind === "model_provider")
.map((item) => item.name),
);
const modelId = `${config.id}-model`;
const providerId = `${config.id}-provider`;
const providerListId = `${config.id}-provider-list`;
const tempId = `${config.id}-temperature`;
const topPId = `${config.id}-top-p`;
const maxTokensId = `${config.id}-max-tokens`;
const updateField = <K extends keyof ModelConfig>(
key: K,
value: ModelConfig[K],
) => {
onUpdate({ [key]: value } as Partial<ModelConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={modelId}
>
Model
</label>
<Input
id={modelId}
className="nodrag"
placeholder="gpt-4o-mini"
value={config.model}
onChange={(event) => updateField("model", event.target.value)}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={providerId}
>
Provider name
</label>
<Input
id={providerId}
className="nodrag"
value={config.provider}
list={providerListId}
onChange={(event) => updateField("provider", event.target.value)}
/>
<datalist id={providerListId}>
{providerOptions.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
</div>
<div className="grid gap-2">
<label className="text-xs font-semibold uppercase text-muted-foreground">
Inference
</label>
<div className="grid grid-cols-3 gap-2">
<Input
id={tempId}
className="nodrag"
placeholder="Temp"
value={config.inference_temperature ?? ""}
onChange={(event) =>
updateField("inference_temperature", event.target.value)
}
/>
<Input
id={topPId}
className="nodrag"
placeholder="Top_p"
value={config.inference_top_p ?? ""}
onChange={(event) =>
updateField("inference_top_p", event.target.value)
}
/>
<Input
id={maxTokensId}
className="nodrag"
placeholder="Max tokens"
value={config.inference_max_tokens ?? ""}
onChange={(event) =>
updateField("inference_max_tokens", event.target.value)
}
/>
</div>
</div>
<label className="flex items-center gap-2 text-xs font-semibold uppercase text-muted-foreground">
<Checkbox
checked={config.skip_health_check ?? false}
onCheckedChange={(value) =>
updateField("skip_health_check", Boolean(value))
}
/>
Skip health check
</label>
</div>
);
}

View file

@ -0,0 +1,130 @@
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
import type { ModelProviderConfig } from "../../types";
import { NameField } from "../shared/name-field";
type ModelProviderDialogProps = {
config: ModelProviderConfig;
onUpdate: (patch: Partial<ModelProviderConfig>) => void;
};
export function ModelProviderDialog({
config,
onUpdate,
}: ModelProviderDialogProps): ReactElement {
const endpointId = `${config.id}-endpoint`;
const providerTypeId = `${config.id}-provider-type`;
const apiKeyEnvId = `${config.id}-api-key-env`;
const apiKeyId = `${config.id}-api-key`;
const extraHeadersId = `${config.id}-extra-headers`;
const extraBodyId = `${config.id}-extra-body`;
const updateField = <K extends keyof ModelProviderConfig>(
key: K,
value: ModelProviderConfig[K],
) => {
onUpdate({ [key]: value } as Partial<ModelProviderConfig>);
};
return (
<div className="space-y-4">
<NameField
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={providerTypeId}
>
Provider type
</label>
<Input
id={providerTypeId}
className="nodrag"
placeholder="openai"
value={config.provider_type}
onChange={(event) =>
updateField("provider_type", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={endpointId}
>
Endpoint
</label>
<Input
id={endpointId}
className="nodrag"
placeholder="https://..."
value={config.endpoint}
onChange={(event) => updateField("endpoint", event.target.value)}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={apiKeyEnvId}
>
API key env (optional)
</label>
<Input
id={apiKeyEnvId}
className="nodrag"
placeholder="OPENAI_API_KEY"
value={config.api_key_env ?? ""}
onChange={(event) => updateField("api_key_env", event.target.value)}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={apiKeyId}
>
API key (optional)
</label>
<Input
id={apiKeyId}
className="nodrag"
value={config.api_key ?? ""}
onChange={(event) => updateField("api_key", event.target.value)}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={extraHeadersId}
>
Extra headers (JSON)
</label>
<Textarea
id={extraHeadersId}
className="nodrag"
placeholder='{"X-Header": "value"}'
value={config.extra_headers ?? ""}
onChange={(event) =>
updateField("extra_headers", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<label
className="text-xs font-semibold uppercase text-muted-foreground"
htmlFor={extraBodyId}
>
Extra body (JSON)
</label>
<Textarea
id={extraBodyId}
className="nodrag"
placeholder='{"key": "value"}'
value={config.extra_body ?? ""}
onChange={(event) => updateField("extra_body", event.target.value)}
/>
</div>
</div>
);
}

View file

@ -136,6 +136,14 @@ export function applyRenameToConfig(
subcategory_parent: to,
};
}
if (config.kind === "model_config" && config.provider === from) {
const base = next === config ? config : next;
next = { ...base, provider: to };
}
if (config.kind === "llm" && config.model_alias === from) {
const base = next === config ? config : next;
next = { ...base, model_alias: to };
}
return next;
}
@ -158,6 +166,14 @@ export function applyRemovalToConfig(
subcategory_mapping: {},
};
}
if (config.kind === "model_config" && config.provider === ref) {
const base = next === config ? config : next;
next = { ...base, provider: "" };
}
if (config.kind === "llm" && config.model_alias === ref) {
const base = next === config ? config : next;
next = { ...base, model_alias: "" };
}
return next;
}

View file

@ -13,6 +13,7 @@ import type {
CanvasNode,
LayoutDirection,
LlmType,
ModelConfig,
NodeConfig,
SamplerConfig,
SamplerType,
@ -51,6 +52,8 @@ type CanvasLabState = {
applyLayout: () => void;
addSamplerNode: (type: SamplerType) => void;
addLlmNode: (type: LlmType) => void;
addModelProviderNode: () => void;
addModelConfigNode: () => void;
addExpressionNode: () => void;
updateConfig: (id: string, patch: Partial<NodeConfig>) => void;
loadCanvas: (snapshot: CanvasSnapshot) => void;
@ -119,6 +122,30 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
return buildNodeUpdate(state, config, state.layoutDirection);
});
},
addModelProviderNode: () => {
set((state) => {
const id = `n${state.nextId}`;
const existing = Object.values(state.configs);
const definition = getBlockDefinition("llm", "model_provider");
if (!definition) {
return state;
}
const config = definition.createConfig(id, existing);
return buildNodeUpdate(state, config, state.layoutDirection);
});
},
addModelConfigNode: () => {
set((state) => {
const id = `n${state.nextId}`;
const existing = Object.values(state.configs);
const definition = getBlockDefinition("llm", "model_config");
if (!definition) {
return state;
}
const config = definition.createConfig(id, existing);
return buildNodeUpdate(state, config, state.layoutDirection);
});
},
addExpressionNode: () => {
set((state) => {
const id = `n${state.nextId}`;
@ -187,12 +214,43 @@ export const useCanvasLabStore = create<CanvasLabState>((set, get) => ({
target: id,
sourceHandle: null,
targetHandle: null,
type: "semantic",
},
edges,
);
}
}
const hasProviderPatch = Object.prototype.hasOwnProperty.call(
patch,
"provider",
);
if (current.kind === "model_config" && hasProviderPatch) {
const nextProvider = (patch as Partial<ModelConfig>).provider ?? "";
edges = edges.filter((edge) => {
if (edge.target !== id) {
return true;
}
const source = configs[edge.source];
return !(source && source.kind === "model_provider");
});
if (nextProvider) {
const providerId = findNodeIdByName(configs, nextProvider);
if (providerId) {
edges = addEdge(
{
source: providerId,
target: id,
sourceHandle: null,
targetHandle: null,
type: "semantic",
},
edges,
);
}
}
}
if (isCategoryConfig(current)) {
const nextCategory = isCategoryConfig(next) ? next : current;
const oldValues = current.values ?? [];

View file

@ -19,9 +19,14 @@ export type LayoutDirection = "LR" | "TB";
export type CanvasNodeData = {
title: string;
name: string;
kind: "sampler" | "llm" | "expression";
kind: "sampler" | "llm" | "expression" | "model_provider" | "model_config";
subtype: string;
blockType: SamplerType | LlmType | "expression";
blockType:
| SamplerType
| LlmType
| "expression"
| "model_provider"
| "model_config";
layoutDirection?: LayoutDirection;
};
@ -94,6 +99,39 @@ export type LlmConfig = {
scores?: Score[];
};
export type ModelProviderConfig = {
id: string;
kind: "model_provider";
name: string;
endpoint: string;
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: string;
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env?: string;
// biome-ignore lint/style/useNamingConvention: api schema
api_key?: string;
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers?: string;
// biome-ignore lint/style/useNamingConvention: api schema
extra_body?: string;
};
export type ModelConfig = {
id: string;
kind: "model_config";
name: string;
model: string;
provider: string;
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature?: string;
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p?: string;
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens?: string;
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check?: boolean;
};
export type ExpressionConfig = {
id: string;
kind: "expression";
@ -102,4 +140,9 @@ export type ExpressionConfig = {
dtype: ExpressionDtype;
};
export type NodeConfig = SamplerConfig | LlmConfig | ExpressionConfig;
export type NodeConfig =
| SamplerConfig
| LlmConfig
| ExpressionConfig
| ModelProviderConfig
| ModelConfig;

View file

@ -45,6 +45,21 @@ function syncSubcategoryMapping(
};
}
function isSemanticEdge(source: NodeConfig, target: NodeConfig): boolean {
if (source.kind === "model_provider" && target.kind === "model_config") {
return true;
}
if (source.kind === "model_config" && target.kind === "llm") {
return true;
}
return (
source.kind === "sampler" &&
source.sampler_type === "category" &&
target.kind === "sampler" &&
target.sampler_type === "subcategory"
);
}
export function isValidCanvasConnection(
connection: Connection,
configs: Record<string, NodeConfig>,
@ -73,8 +88,19 @@ export function applyCanvasConnection(
if (!(source && target)) {
return { edges };
}
const nextEdges = addEdge({ ...connection, type: "canvas" }, edges);
if (isLlmConfig(target)) {
const nextEdges = addEdge(
{ ...connection, type: isSemanticEdge(source, target) ? "semantic" : "canvas" },
edges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
const next = { ...target, provider: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (source.kind === "model_config" && target.kind === "llm") {
const next = { ...target, model_alias: source.name };
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isLlmConfig(target) && source.kind !== "model_provider" && source.kind !== "model_config") {
const ref = `{{ ${source.name} }}`;
const next = {
...target,
@ -82,7 +108,7 @@ export function applyCanvasConnection(
};
return { edges: nextEdges, configs: { ...configs, [target.id]: next } };
}
if (isExpressionConfig(target)) {
if (isExpressionConfig(target) && source.kind !== "model_provider" && source.kind !== "model_config") {
const ref = `{{ ${source.name} }}`;
const next = {
...target,

View file

@ -5,11 +5,11 @@ import { extractRefs } from "./helpers";
export function buildEdges(
configs: NodeConfig[],
nameToId: Map<string, string>,
uiEdges: Array<{ from: string; to: string }> | null,
uiEdges: Array<{ from: string; to: string; type?: string }> | null,
): Edge[] {
const edges: Edge[] = [];
const seen = new Set<string>();
const addEdgeByName = (from: string, to: string) => {
const addEdgeByName = (from: string, to: string, type?: string) => {
const sourceId = nameToId.get(from);
const targetId = nameToId.get(to);
if (!(sourceId && targetId)) {
@ -24,13 +24,13 @@ export function buildEdges(
id: `e-${key}`,
source: sourceId,
target: targetId,
type: "canvas",
type: type ?? "canvas",
});
};
if (uiEdges && uiEdges.length > 0) {
for (const edge of uiEdges) {
addEdgeByName(edge.from, edge.to);
addEdgeByName(edge.from, edge.to, edge.type);
}
return edges;
}
@ -54,7 +54,13 @@ export function buildEdges(
config.sampler_type === "subcategory" &&
config.subcategory_parent
) {
addEdgeByName(config.subcategory_parent, config.name);
addEdgeByName(config.subcategory_parent, config.name, "semantic");
}
if (config.kind === "model_config" && config.provider) {
addEdgeByName(config.provider, config.name, "semantic");
}
if (config.kind === "llm" && config.model_alias) {
addEdgeByName(config.model_alias, config.name, "semantic");
}
}

View file

@ -1,7 +1,11 @@
import type { NodeConfig } from "../../types";
import { buildEdges } from "./edges";
import { isRecord, parseJson } from "./helpers";
import { parseColumn } from "./parsers";
import { isRecord, parseJson, readString } from "./helpers";
import {
parseColumn,
parseModelConfig,
parseModelProvider,
} from "./parsers";
import { buildNodes, parseUi } from "./ui";
import type { ImportResult } from "./types";
@ -39,12 +43,61 @@ export function importCanvasPayload(input: string): ImportResult {
const configs: NodeConfig[] = [];
const nameToId = new Map<string, string>();
let nextId = 1;
if (Array.isArray(recipe.model_providers)) {
recipe.model_providers.forEach((provider, index) => {
if (!isRecord(provider)) {
errors.push(`Model provider ${index + 1}: invalid object.`);
return;
}
const name = readString(provider.name);
if (!name) {
errors.push(`Model provider ${index + 1}: missing name.`);
return;
}
const id = `n${nextId}`;
nextId += 1;
const config = parseModelProvider(provider, name, id);
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
return;
}
nameToId.set(config.name, config.id);
configs.push(config);
});
}
if (Array.isArray(recipe.model_configs)) {
recipe.model_configs.forEach((model, index) => {
if (!isRecord(model)) {
errors.push(`Model config ${index + 1}: invalid object.`);
return;
}
const name = readString(model.alias) ?? readString(model.name);
if (!name) {
errors.push(`Model config ${index + 1}: missing alias.`);
return;
}
const id = `n${nextId}`;
nextId += 1;
const config = parseModelConfig(model, name, id);
if (nameToId.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
return;
}
nameToId.set(config.name, config.id);
configs.push(config);
});
}
recipe.columns.forEach((column, index) => {
if (!isRecord(column)) {
errors.push(`Column ${index + 1}: invalid object.`);
return;
}
const id = `n${index + 1}`;
const id = `n${nextId}`;
nextId += 1;
const config = parseColumn(column, id, errors);
if (!config) {
return;
@ -76,7 +129,7 @@ export function importCanvasPayload(input: string): ImportResult {
configs: Object.fromEntries(configs.map((config) => [config.id, config])),
nodes,
edges,
nextId: configs.length + 1,
nextId,
nextY: maxY + 140,
},
};

View file

@ -2,6 +2,8 @@ import type {
ExpressionConfig,
ExpressionDtype,
LlmConfig,
ModelConfig,
ModelProviderConfig,
NodeConfig,
SamplerConfig,
SamplerType,
@ -234,6 +236,61 @@ function parseLlm(
};
}
export function parseModelProvider(
provider: Record<string, unknown>,
name: string,
id: string,
): ModelProviderConfig {
return {
id,
kind: "model_provider",
name,
endpoint: readString(provider.endpoint) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: readString(provider.provider_type) ?? "openai",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: readString(provider.api_key_env) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: readString(provider.api_key) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: isRecord(provider.extra_headers)
? JSON.stringify(provider.extra_headers, null, 2)
: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: isRecord(provider.extra_body)
? JSON.stringify(provider.extra_body, null, 2)
: "",
};
}
export function parseModelConfig(
model: Record<string, unknown>,
name: string,
id: string,
): ModelConfig {
const inference = isRecord(model.inference_parameters)
? (model.inference_parameters as Record<string, unknown>)
: {};
return {
id,
kind: "model_config",
name,
model: readString(model.model) ?? "",
provider: readString(model.provider) ?? "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: readNumberString(inference.temperature),
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p: readNumberString(inference.top_p),
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens: readNumberString(inference.max_tokens),
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check:
typeof model.skip_health_check === "boolean"
? model.skip_health_check
: false,
};
}
function parseExpression(
column: Record<string, unknown>,
name: string,

View file

@ -11,10 +11,10 @@ export function parseUi(
ui: UiInput | null,
): {
positions: Map<string, { x: number; y: number }>;
edges: Array<{ from: string; to: string }> | null;
edges: Array<{ from: string; to: string; type?: string }> | null;
} {
const positions = new Map<string, { x: number; y: number }>();
const edges: Array<{ from: string; to: string }> = [];
const edges: Array<{ from: string; to: string; type?: string }> = [];
if (ui && Array.isArray(ui.nodes)) {
for (const node of ui.nodes) {
if (isRecord(node)) {
@ -33,7 +33,11 @@ export function parseUi(
const from = readString(edge.from);
const to = readString(edge.to);
if (from && to) {
edges.push({ from, to });
edges.push({
from,
to,
type: readString(edge.type) ?? undefined,
});
}
}
}

View file

@ -5,6 +5,8 @@ import type {
LayoutDirection,
LlmConfig,
LlmType,
ModelConfig,
ModelProviderConfig,
NodeConfig,
SamplerConfig,
SamplerType,
@ -220,6 +222,49 @@ export function makeLlmConfig(
};
}
export function makeModelProviderConfig(
id: string,
existing: NodeConfig[],
): ModelProviderConfig {
return {
id,
kind: "model_provider",
name: nextName(existing, "provider"),
endpoint: "",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "openai",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "",
// biome-ignore lint/style/useNamingConvention: api schema
api_key: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: "",
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: "",
};
}
export function makeModelConfig(
id: string,
existing: NodeConfig[],
): ModelConfig {
return {
id,
kind: "model_config",
name: nextName(existing, "model"),
model: "",
provider: "",
// biome-ignore lint/style/useNamingConvention: api schema
inference_temperature: "0.7",
// biome-ignore lint/style/useNamingConvention: api schema
inference_max_tokens: "256",
// biome-ignore lint/style/useNamingConvention: api schema
inference_top_p: "",
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check: false,
};
}
export function makeExpressionConfig(
id: string,
existing: NodeConfig[],
@ -269,6 +314,26 @@ export function nodeDataFromConfig(
layoutDirection,
};
}
if (config.kind === "model_provider") {
return {
title: "Model Provider",
kind: "model_provider",
subtype: config.provider_type || "Provider",
blockType: "model_provider",
name: config.name,
layoutDirection,
};
}
if (config.kind === "model_config") {
return {
title: "Model Config",
kind: "model_config",
subtype: config.model || "Model",
blockType: "model_config",
name: config.name,
layoutDirection,
};
}
return {
title: "LLM",
kind: "llm",

View file

@ -3,35 +3,13 @@ import type {
CanvasNode,
ExpressionConfig,
LlmConfig,
ModelConfig,
ModelProviderConfig,
NodeConfig,
SamplerConfig,
} from "../types";
import { getConfigErrors } from "./index";
const DEFAULT_PROVIDER = {
name: "openrouter",
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: "openai",
endpoint: "https://openrouter.ai/api/v1",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: "OPENROUTER_API_KEY",
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: {},
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: {},
};
const DEFAULT_CONFIG = {
provider: "openrouter",
model: "allenai/olmo-3.1-32b-instruct",
// biome-ignore lint/style/useNamingConvention: api schema
inference_parameters: {
temperature: 0.7,
// biome-ignore lint/style/useNamingConvention: api schema
max_tokens: 256,
},
};
type CanvasPayload = {
recipe: {
// biome-ignore lint/style/useNamingConvention: api schema
@ -49,7 +27,7 @@ type CanvasPayload = {
};
ui: {
nodes: { id: string; x: number; y: number }[];
edges: { from: string; to: string }[];
edges: { from: string; to: string; type?: string }[];
};
};
@ -82,6 +60,94 @@ function parseAgeRange(value?: string): [number, number] | 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 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;
@ -282,6 +348,11 @@ export function buildCanvasPayload(
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>();
@ -294,7 +365,7 @@ export function buildCanvasPayload(
errors.push(`${config.name}: ${error}`);
}
if (nameSet.has(config.name)) {
errors.push(`Duplicate column name: ${config.name}.`);
errors.push(`Duplicate node name: ${config.name}.`);
}
nameSet.add(config.name);
@ -316,9 +387,16 @@ export function buildCanvasPayload(
modelAliases.add(config.model_alias);
}
nameToConfig.set(config.name, config);
} else {
} 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);
}
}
@ -347,14 +425,41 @@ export function buildCanvasPayload(
}
}
const modelProviders = modelAliases.size > 0 ? [DEFAULT_PROVIDER] : [];
const modelConfigs =
modelAliases.size > 0
? Array.from(modelAliases).map((alias) => ({
alias,
...DEFAULT_CONFIG,
}))
: [];
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];
@ -380,6 +485,7 @@ export function buildCanvasPayload(
{
from: source.name,
to: target.name,
type: edge.type ?? "canvas",
},
];
});