Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aa438ce
feat: inject local model provider into recipe jobs via JWT
wasimysaid Apr 3, 2026
0beffea
feat: auto-generate JWT for local model providers in recipes
wasimysaid Apr 3, 2026
19d657a
feat: add is_local flag to model provider config types and utils
wasimysaid Apr 3, 2026
694d3c4
fix(studio): skip endpoint validation for local providers
wasimysaid Apr 3, 2026
b4e9ff6
feat(studio): add local/external model source toggle to provider dialog
wasimysaid Apr 3, 2026
8028495
feat(studio): thread localProviderNames through model config dialog c…
wasimysaid Apr 3, 2026
754eb54
feat(studio): show 'Local model (Chat)' label for local model_provide…
wasimysaid Apr 3, 2026
b351ffa
fix: hardcode loopback for local endpoint, clear stale creds on toggle
wasimysaid Apr 3, 2026
c83b3db
fix: document TOCTOU/JWT rotation, add deferred import comments, fix …
wasimysaid Apr 3, 2026
b3ae2dd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 3, 2026
100ba0b
Merge branch 'main' into feat/local-model-provider
wasimysaid Apr 7, 2026
4c997e9
fix(studio): clear stale local model state on provider toggle and val…
wasimysaid Apr 7, 2026
1b19db5
fix(studio): override empty local endpoint in validation and skip mod…
wasimysaid Apr 7, 2026
10f1fea
fix(studio): resolve loopback port from app.state, clear stale local …
Apr 8, 2026
1259ebd
fix(studio): narrow store cascade types, sync model placeholder on gr…
Apr 8, 2026
1cbc8a3
fix(studio): strict is_local check, narrow loaded-model gate to LLM-r…
Apr 8, 2026
d21b077
fix(studio): force skip_health_check on local-linked configs, skip JS…
Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion studio/backend/routes/data_recipe/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,96 @@
RecipePayload,
)

from datetime import timedelta

router = APIRouter()


def _inject_local_providers(recipe: dict[str, Any], request_base_url: str) -> None:
"""
Mutate recipe dict in-place: for any provider with is_local=True,
generate a 24h JWT and fill in the endpoint pointing at this server.
"""
providers = recipe.get("model_providers")
if not providers:
return

# Collect local providers and pop is_local from ALL dicts unconditionally
local_indices: list[int] = []
for i, provider in enumerate(providers):
if not isinstance(provider, dict):
continue
if provider.pop("is_local", None):
local_indices.append(i)

if not local_indices:
return

# Only gate on model-loaded if a local provider is actually referenced
# by a model_config. Unused local provider nodes should not block runs.
local_names = {providers[i].get("name") for i in local_indices}
referenced_providers = {
mc.get("provider")
for mc in recipe.get("model_configs", [])
if isinstance(mc, dict)
}
if not local_names & referenced_providers:
# No model_config actually uses a local provider, just inject and move on
from urllib.parse import urlparse

parsed = urlparse(request_base_url)
port = parsed.port or 8888
endpoint = f"http://127.0.0.1:{port}/v1"
for i in local_indices:
providers[i]["endpoint"] = endpoint
providers[i]["api_key"] = ""
providers[i]["provider_type"] = "openai"
return

# Verify a model is loaded.
# NOTE: This is a point-in-time check (TOCTOU). The model could be unloaded
# or swapped after this check but before the recipe subprocess calls /v1.
# The inference endpoint returns a clear 400 in that case.
#
# Imports are deferred to avoid circular dependencies with inference modules.
from routes.inference import get_llama_cpp_backend
from core.inference import get_inference_backend

llama = get_llama_cpp_backend()
model_loaded = llama.is_loaded
if not model_loaded:
backend = get_inference_backend()
model_loaded = bool(backend.active_model_name)
if not model_loaded:
raise ValueError(
"No model loaded in Chat. Load a model first, then run the recipe."
)
Comment thread
wasimysaid marked this conversation as resolved.
Outdated

from auth.authentication import (
create_access_token,
) # deferred: avoids circular import

# Uses the "unsloth" admin subject. If the user changes their password,
# the JWT secret rotates and this token becomes invalid mid-run.
# Acceptable for v1 — recipes typically finish well within one session.
token = create_access_token(
subject = "unsloth",
expires_delta = timedelta(hours = 24),
)

# Always use loopback — request.base_url may reflect a proxy or 0.0.0.0
from urllib.parse import urlparse # deferred: only needed for local providers

parsed = urlparse(request_base_url)
port = parsed.port or 8888
endpoint = f"http://127.0.0.1:{port}/v1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive local inference endpoint from backend bind port

This endpoint is computed from the client-facing request_base_url port (or 8888 fallback), which can differ from the backend's real listen port when Studio is behind a reverse proxy/TLS terminator. In that setup, local-provider jobs point to the wrong loopback URL (127.0.0.1:<external-port>/v1) and fail to connect even though Chat is loaded, so local mode becomes unreliable outside direct localhost access.

Useful? React with 👍 / 👎.


for i in local_indices:
providers[i]["endpoint"] = endpoint
providers[i]["api_key"] = token
providers[i]["provider_type"] = "openai"


def _normalize_run_name(value: Any) -> str | None:
if value is None:
return None
Expand All @@ -40,7 +127,7 @@ def _normalize_run_name(value: Any) -> str | None:


@router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse)
def create_job(payload: RecipePayload):
def create_job(payload: RecipePayload, request: Request):
recipe = payload.recipe
if not recipe.get("columns"):
raise HTTPException(status_code = 400, detail = "Recipe must include columns.")
Expand All @@ -67,6 +154,11 @@ def create_job(payload: RecipePayload):
status_code = 400, detail = f"invalid run_config: {exc}"
) from exc

try:
_inject_local_providers(recipe, str(request.base_url))
except ValueError as exc:
raise HTTPException(status_code = 400, detail = str(exc)) from exc

mgr = get_job_manager()
try:
job_id = mgr.start(recipe = recipe, run = run)
Expand Down
11 changes: 11 additions & 0 deletions studio/backend/routes/data_recipe/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
return errors


def _patch_local_providers(recipe: dict[str, Any]) -> None:
"""Strip is_local and fill a dummy endpoint so validation doesn't choke."""
for provider in recipe.get("model_providers", []):
if not isinstance(provider, dict):
continue
if provider.pop("is_local", None):
provider["endpoint"] = "http://127.0.0.1"
Comment on lines +81 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align local-provider validation patch with job injection

The validation path only rewrites local providers to endpoint="http://127.0.0.1", but it does not apply the same local-mode mutations used in create_job (JWT injection and skip_health_check on linked model configs). Because validate() still calls validate_recipe(recipe), local recipes that use placeholder model IDs like "local" can fail preflight model/provider checks during “Check recipe” even though the run path succeeds after _inject_local_providers mutates the payload.

Useful? React with 👍 / 👎.



@router.post("/validate", response_model = ValidateResponse)
def validate(payload: RecipePayload) -> ValidateResponse:
recipe = payload.recipe
Expand All @@ -77,6 +86,8 @@ def validate(payload: RecipePayload) -> ValidateResponse:
errors = [ValidateError(message = "Recipe must include columns.")],
)

_patch_local_providers(recipe)

try:
validate_recipe(recipe)
except RuntimeError as exc:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function renderBlockDialog(
categoryOptions: SamplerConfig[],
modelConfigAliases: string[],
modelProviderOptions: string[],
localProviderNames: Set<string>,
toolProfileAliases: string[],
datetimeOptions: string[],
onUpdate: (id: string, patch: Partial<NodeConfig>) => void,
Expand Down Expand Up @@ -109,6 +110,7 @@ export function renderBlockDialog(
<ModelConfigDialog
config={config}
providerOptions={modelProviderOptions}
localProviderNames={localProviderNames}
onUpdate={update}
/>
) : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ type InlineModelProps = {

export function InlineModel(props: InlineModelProps): ReactElement {
if (props.config.kind === "model_provider") {
if (props.config.is_local) {
return (
<div className="flex items-center gap-2 px-1 py-0.5">
<span className="text-xs font-medium text-muted-foreground">
Local model (Chat)
</span>
</div>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2">
<InlineField label="Endpoint">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type ConfigDialogProps = {
categoryOptions: SamplerConfig[];
modelConfigAliases: string[];
modelProviderOptions: string[];
localProviderNames: Set<string>;
toolProfileAliases: string[];
datetimeOptions: string[];
onUpdate: (id: string, patch: Partial<NodeConfig>) => void;
Expand All @@ -32,6 +33,7 @@ export function ConfigDialog({
categoryOptions,
modelConfigAliases,
modelProviderOptions,
localProviderNames,
toolProfileAliases,
datetimeOptions,
onUpdate,
Expand Down Expand Up @@ -101,6 +103,7 @@ export function ConfigDialog({
categoryOptions,
modelConfigAliases,
modelProviderOptions,
localProviderNames,
toolProfileAliases,
datetimeOptions,
onUpdate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,17 @@ import { NameField } from "../shared/name-field";
type ModelConfigDialogProps = {
config: ModelConfig;
providerOptions: string[];
localProviderNames: Set<string>;
onUpdate: (patch: Partial<ModelConfig>) => void;
};

export function ModelConfigDialog({
config,
providerOptions,
localProviderNames,
onUpdate,
}: ModelConfigDialogProps): ReactElement {
const isLinkedToLocal = localProviderNames.has(config.provider);
const [optionalOpen, setOptionalOpen] = useState(false);
const modelId = `${config.id}-model`;
const providerId = `${config.id}-provider`;
Expand Down Expand Up @@ -84,7 +87,17 @@ export function ModelConfigDialog({
filteredItems={providerOptions}
filter={null}
value={config.provider || null}
onValueChange={(value) => updateField("provider", value ?? "")}
onValueChange={(value) => {
const selectedProvider = value ?? "";
const isLocal = localProviderNames.has(selectedProvider);
if (isLocal && !config.model.trim()) {
onUpdate({ provider: selectedProvider, model: "local" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply local-model reset when provider changes via blur

The new local/external model-id normalization only runs in onValueChange, but provider edits committed via free-text blur still take the old onBlur path and skip this logic. A common path is: select local provider (auto-fills model to local), then type an external provider name and blur—provider updates, but model stays local, which then gets sent to the external endpoint and causes avoidable run-time failures.

Useful? React with 👍 / 👎.

} else if (!isLocal && config.model === "local") {
onUpdate({ provider: selectedProvider, model: "" });
} else {
updateField("provider", selectedProvider);
}
}}
onInputValueChange={(value) => {
providerInputRef.current = value;
}}
Expand Down Expand Up @@ -124,12 +137,12 @@ export function ModelConfigDialog({
<FieldLabel
label="Model ID"
htmlFor={modelId}
hint="The exact model name sent to the connection."
hint={isLinkedToLocal ? "Uses the model loaded in Chat. Any value works here." : "The exact model name sent to the connection."}
/>
<Input
id={modelId}
className="nodrag"
placeholder="gpt-4o-mini"
placeholder={isLinkedToLocal ? "local" : "gpt-4o-mini"}
value={config.model}
onChange={(event) => updateField("model", event.target.value)}
/>
Expand Down
Loading