From 8e977445d43350e3bc74b67544a5fbdbde29bb52 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Wed, 8 Apr 2026 12:48:22 +0200 Subject: [PATCH] Let recipes use the model loaded in Chat (#4840) * feat: inject local model provider into recipe jobs via JWT * feat: auto-generate JWT for local model providers in recipes * feat: add is_local flag to model provider config types and utils * fix(studio): skip endpoint validation for local providers * feat(studio): add local/external model source toggle to provider dialog * feat(studio): thread localProviderNames through model config dialog chain * feat(studio): show 'Local model (Chat)' label for local model_provider configs * fix: hardcode loopback for local endpoint, clear stale creds on toggle * fix: document TOCTOU/JWT rotation, add deferred import comments, fix is_local serialization * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): clear stale local model state on provider toggle and validation * fix(studio): override empty local endpoint in validation and skip model gate for unused providers * fix(studio): resolve loopback port from app.state, clear stale local provider fields, sync model id on toggle Address review feedback on the local-model-provider flow: - Backend (jobs.py): _resolve_local_v1_endpoint now reads the actual bound port from app.state.server_port (set in run.py after binding) instead of parsing it out of request.base_url, which is wrong behind any reverse proxy or non-default port. The two duplicated urlparse blocks are gone. - Backend (jobs.py): defensively pop api_key_env, extra_headers, extra_body from local providers so a previously external provider that flipped to local cannot leak invalid JSON or rogue auth headers into the local /v1 call. Also dedupe the post-loop assignment and tighten the local-name intersection so empty names cannot match. - Backend (jobs.py): hoist datetime and urllib.parse imports to the top import block for consistency with the rest of the file. - Backend (run.py): expose the bound port on app.state.server_port after the uvicorn server is constructed. - Frontend (model-provider-dialog.tsx): clear extra_headers and extra_body when toggling to local mode. Hidden inputs would otherwise keep stale JSON blocking validate/run. - Frontend (model-config-dialog.tsx): factor the local-aware provider selection logic into applyProviderChange and call it from both onValueChange and onBlur, so manually typing a provider name and tabbing away keeps the model field consistent. - Frontend (recipe-studio.ts store): handle both directions of the is_local toggle in the cascade. external -> local now backfills model: "local" on already-linked model_configs so they pass validation immediately, mirroring the existing local -> external clear path. - Frontend (validate.ts + build-payload.ts): thread localProviderNames into validateModelConfigProviders and skip the "model is required" check for local-linked configs. Local providers do not need a real model id since the inference endpoint uses the loaded Chat model. * fix(studio): narrow store cascade types, sync model placeholder on graph relink and node removal, harden ephemeral port path Loop 2 review fixes: - recipe-studio.ts: type-narrow next.is_local by also checking next.kind === "model_provider". TS otherwise raised TS2339 because next was typed as the union NodeConfig after the spread. The behavior is unchanged but the code now compiles cleanly. - model-config-dialog.tsx: convert the lastProviderRef / providerInputRef ref-during-render pattern (pre-existing react-hooks/refs lint error) to a useEffect that syncs providerInputRef from config.provider. The combobox blur path still uses applyProviderChange and remains stable. - recipe-graph-connection.ts: when a graph drag links a model_provider to a model_config, mirror the dialog applyProviderChange behavior: fill model: "local" if the new provider is local and the model field is blank, clear model when relinking from a local placeholder to an external provider, otherwise leave the model alone. - reference-sync.ts: when a referenced provider node is removed, clear the synthetic model: "local" placeholder along with the provider field, so a future relink to an external provider does not pass validation with a stale value that fails at runtime. - run.py: only publish app.state.server_port when the bound port is a real positive integer; for ephemeral binds (port==0) leave it unset and let request handlers fall back to request.base_url. - jobs.py: _resolve_local_v1_endpoint also falls back when app.state.server_port is non-positive, and uses `is None` instead of the truthy fallback so a literal 0 is handled correctly. * fix(studio): strict is_local check, narrow loaded-model gate to LLM-reachable configs, add scope-server port fallback Loop 3 review fixes: - jobs.py, validate.py: require `is_local is True` instead of truthy check. Malformed payloads such as is_local: "false" or is_local: 1 would otherwise be treated as local and silently rewritten to the loopback endpoint. - jobs.py: _resolve_local_v1_endpoint now tries request.scope["server"] (the actual uvicorn-assigned (host, port) tuple) as a second resolution step before falling back to parsing request.base_url. This covers direct-uvicorn startup paths and ephemeral binds that never publish app.state.server_port. - jobs.py: new _used_llm_model_aliases helper collects the set of model_aliases that an LLM column actually references, and the "Chat model loaded" gate is now only triggered when a local provider is reachable from that set. Orphan model_config nodes on the canvas no longer block unrelated recipe runs. * fix(studio): force skip_health_check on local-linked configs, skip JSON parsing for local providers, local-aware inline editor Loop 4 review fixes: - jobs.py: after rewriting local providers, also force skip_health_check: true on any model_config linked to a local provider. The /v1/models endpoint only advertises the real loaded model id, so data_designer's default model-availability health check would otherwise fail against the placeholder "local" id before the first chat completion call. The inference route already ignores the model id in chat completions, so skipping the check is safe. - builders-model.ts: buildModelProvider now short-circuits for local providers and emits only { name, endpoint: "", provider_type, is_local } without running parseJsonObject on the hidden extra_headers/extra_body inputs. Imported or hydrated recipes with stale invalid JSON in those fields no longer block client-side validate/run. - inline-model.tsx: the model_config branch now accepts an optional localProviderNames prop and mirrors the dialog applyProviderChange behavior. Changing provider to/from a local one auto-fills or clears the "local" placeholder consistently with the other edit paths. - recipe-graph-node.tsx: derive localProviderNames from the store via useMemo (stable identity) and pass it through renderNodeBody to . Hooks order is preserved by declaring them above the early return for markdown_note nodes. - run.py: minor comment tweak - loop 3 already added the scope-server fallback path, note that in the comment. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen --- studio/backend/routes/data_recipe/jobs.py | 164 ++++++++++++- studio/backend/routes/data_recipe/validate.py | 16 ++ studio/backend/run.py | 8 + .../recipe-studio/blocks/render-dialog.tsx | 2 + .../components/inline/inline-model.tsx | 39 +++- .../components/recipe-graph-node.tsx | 28 ++- .../recipe-studio/dialogs/config-dialog.tsx | 3 + .../dialogs/models/model-config-dialog.tsx | 38 ++- .../dialogs/models/model-provider-dialog.tsx | 218 ++++++++++++------ .../recipe-studio/recipe-studio-page.tsx | 1 + .../stores/helpers/reference-sync.ts | 10 +- .../recipe-studio/stores/recipe-studio.ts | 28 +++ .../src/features/recipe-studio/types/index.ts | 2 + .../recipe-studio/utils/config-factories.ts | 2 + .../utils/graph/recipe-graph-connection.ts | 12 +- .../utils/import/parsers/model-parser.ts | 2 + .../utils/payload/build-payload.ts | 5 + .../utils/payload/builders-model.ts | 20 ++ .../recipe-studio/utils/payload/validate.ts | 9 +- .../recipe-studio/utils/recipe-studio-view.ts | 6 + 20 files changed, 518 insertions(+), 95 deletions(-) diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py index 1d5eceee03..00546b47a4 100644 --- a/studio/backend/routes/data_recipe/jobs.py +++ b/studio/backend/routes/data_recipe/jobs.py @@ -5,7 +5,9 @@ from __future__ import annotations +from datetime import timedelta from typing import Any +from urllib.parse import urlparse from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import JSONResponse, StreamingResponse @@ -26,6 +28,161 @@ from models.data_recipe import ( router = APIRouter() +def _resolve_local_v1_endpoint(request: Request) -> str: + """Return the loopback /v1 URL for the actual backend listen port. + + Resolution order: + 1. ``app.state.server_port`` - explicitly published by run.py after + the uvicorn server has bound. This is the most reliable source + because it survives reverse proxies, TLS terminators and tunnels. + 2. ``request.scope["server"]`` - the real (host, port) tuple uvicorn + sets when the request is dispatched. Used when Studio is started + outside ``run_server`` (e.g. ``uvicorn studio.backend.main:app``). + 3. ``request.base_url`` parsed - last resort for test fixtures that + do not route through a live uvicorn server. + """ + port: Any = getattr(request.app.state, "server_port", None) + if not isinstance(port, int) or port <= 0: + server = request.scope.get("server") + if ( + isinstance(server, tuple) + and len(server) >= 2 + and isinstance(server[1], int) + and server[1] > 0 + ): + port = server[1] + else: + parsed = urlparse(str(request.base_url)) + port = parsed.port if parsed.port is not None else 8888 + return f"http://127.0.0.1:{int(port)}/v1" + + +def _used_llm_model_aliases(recipe: dict[str, Any]) -> set[str]: + """Return the set of model_aliases that are actually referenced by an + LLM column. Used to narrow the "Chat model loaded" gate so that orphan + model_config nodes on the canvas do not block unrelated recipe runs. + + The ``llm-`` prefix matches the existing convention in + ``core/data_recipe/service.py::_recipe_has_llm_columns`` and covers all + LLM column types emitted by the frontend (llm-text, llm-code, + llm-structured, llm-judge). + """ + aliases: set[str] = set() + for column in recipe.get("columns", []): + if not isinstance(column, dict): + continue + column_type = column.get("column_type") + if not isinstance(column_type, str) or not column_type.startswith("llm-"): + continue + alias = column.get("model_alias") + if isinstance(alias, str) and alias: + aliases.add(alias) + return aliases + + +def _inject_local_providers(recipe: dict[str, Any], request: Request) -> None: + """ + Mutate recipe dict in-place: for any provider with is_local=True, + generate a 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. + # Strict `is True` guard so malformed payloads (is_local: 1, + # is_local: "true") do not accidentally trigger the loopback rewrite. + local_indices: list[int] = [] + for i, provider in enumerate(providers): + if not isinstance(provider, dict): + continue + is_local = provider.pop("is_local", None) + if is_local is True: + local_indices.append(i) + + if not local_indices: + return + + endpoint = _resolve_local_v1_endpoint(request) + + # Only gate on model-loaded if a local provider is actually reachable + # from an LLM column through a model_config. Orphan model_config nodes + # that reference a local provider but that no LLM column uses should + # not block runs; the recipe would never call /v1 for them. + local_names = { + providers[i].get("name") for i in local_indices if providers[i].get("name") + } + used_aliases = _used_llm_model_aliases(recipe) + referenced_providers = { + mc.get("provider") + for mc in recipe.get("model_configs", []) + if ( + isinstance(mc, dict) + and mc.get("provider") + and mc.get("alias") in used_aliases + ) + } + + token = "" + if local_names & referenced_providers: + # 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." + ) + + 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), + ) + + # Defensively strip any stale "external"-only fields the frontend may + # have left on the dict (extra_headers/extra_body/api_key_env). The UI + # hides these inputs in local mode but the payload builder still serializes + # them, so a previously external provider that flipped to local can carry + # invalid JSON or rogue auth headers into the local /v1 call. + for i in local_indices: + providers[i]["endpoint"] = endpoint + providers[i]["api_key"] = token + providers[i]["provider_type"] = "openai" + providers[i].pop("api_key_env", None) + providers[i].pop("extra_headers", None) + providers[i].pop("extra_body", None) + + # Force skip_health_check on any model_config that references a local + # provider. The local /v1/models endpoint only lists the real loaded + # model (e.g. "unsloth/llama-3.2-1b") and not the placeholder "local" + # that the recipe sends as the model id, so data_designer's pre-flight + # health check would otherwise fail before the first completion call. + # The backend route ignores the model id field in chat completions, so + # skipping the check is safe. + for mc in recipe.get("model_configs", []): + if not isinstance(mc, dict): + continue + if mc.get("provider") in local_names: + mc["skip_health_check"] = True + + def _normalize_run_name(value: Any) -> str | None: if value is None: return None @@ -40,7 +197,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.") @@ -67,6 +224,11 @@ def create_job(payload: RecipePayload): status_code = 400, detail = f"invalid run_config: {exc}" ) from exc + try: + _inject_local_providers(recipe, request) + 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) diff --git a/studio/backend/routes/data_recipe/validate.py b/studio/backend/routes/data_recipe/validate.py index a793a3b172..555e3eaa06 100644 --- a/studio/backend/routes/data_recipe/validate.py +++ b/studio/backend/routes/data_recipe/validate.py @@ -68,6 +68,20 @@ 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. + + Uses a strict `is True` check to match _inject_local_providers in + jobs.py - malformed payloads with truthy but non-boolean is_local + values should not be treated as local. + """ + for provider in recipe.get("model_providers", []): + if not isinstance(provider, dict): + continue + if provider.pop("is_local", None) is True: + provider["endpoint"] = "http://127.0.0.1" + + @router.post("/validate", response_model = ValidateResponse) def validate(payload: RecipePayload) -> ValidateResponse: recipe = payload.recipe @@ -77,6 +91,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: diff --git a/studio/backend/run.py b/studio/backend/run.py index 9c3622988e..86c1194661 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -324,6 +324,14 @@ def run_server( _server = uvicorn.Server(config) _shutdown_event = Event() + # Expose the actual bound port so request-handling code can build + # loopback URLs that point at the real backend, not whatever port a + # reverse proxy or tunnel exposed in the request URL. Only publish + # an explicit value when we know the concrete port; for ephemeral + # binds (port==0) leave it unset and let request handlers fall back + # to the ASGI request scope or request.base_url. + app.state.server_port = port if port and port > 0 else None + # Run server in a daemon thread def _run(): asyncio.run(_server.serve()) diff --git a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx index 10fcaa489d..92f72dfff1 100644 --- a/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/blocks/render-dialog.tsx @@ -28,6 +28,7 @@ export function renderBlockDialog( categoryOptions: SamplerConfig[], modelConfigAliases: string[], modelProviderOptions: string[], + localProviderNames: Set, toolProfileAliases: string[], datetimeOptions: string[], onUpdate: (id: string, patch: Partial) => void, @@ -109,6 +110,7 @@ export function renderBlockDialog( ) : null; diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx index d3ca22b00b..16e99f4fae 100644 --- a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx +++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx @@ -10,11 +10,21 @@ type InlineModelPatch = Partial | Partial; type InlineModelProps = { config: ModelProviderConfig | ModelConfig; + localProviderNames?: Set; onUpdate: (patch: InlineModelPatch) => void; }; export function InlineModel(props: InlineModelProps): ReactElement { if (props.config.kind === "model_provider") { + if (props.config.is_local) { + return ( +
+ + Local model (Chat) + +
+ ); + } return (
@@ -42,21 +52,40 @@ export function InlineModel(props: InlineModelProps): ReactElement { ); } + // model_config branch - mirror the local-aware provider sync from the + // dialog path so inline edits do not leave stale "local" placeholders + // on external providers and fill the placeholder when switching to local. + const localNames = props.localProviderNames ?? new Set(); + const modelConfig = props.config; + const handleProviderChange = (nextProvider: string) => { + const isLocal = localNames.has(nextProvider); + if (isLocal && !modelConfig.model.trim()) { + props.onUpdate({ provider: nextProvider, model: "local" }); + return; + } + if (!isLocal && modelConfig.model === "local") { + props.onUpdate({ provider: nextProvider, model: "" }); + return; + } + props.onUpdate({ provider: nextProvider }); + }; + const isLinkedToLocal = localNames.has(modelConfig.provider); + return (
props.onUpdate({ provider: event.target.value })} + value={modelConfig.provider} + onChange={(event) => handleProviderChange(event.target.value)} /> props.onUpdate({ model: event.target.value })} /> @@ -65,7 +94,7 @@ export function InlineModel(props: InlineModelProps): ReactElement { className="nodrag h-8 w-full text-xs" type="number" placeholder="0.7" - value={props.config.inference_temperature ?? ""} + value={modelConfig.inference_temperature ?? ""} onChange={(event) => props.onUpdate({ // biome-ignore lint/style/useNamingConvention: api schema diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx index 0fcf202190..8afc4e26bf 100644 --- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx +++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx @@ -30,7 +30,7 @@ import { Position, useUpdateNodeInternals, } from "@xyflow/react"; -import { type ReactElement, memo, useEffect } from "react"; +import { type ReactElement, memo, useEffect, useMemo } from "react"; import { MAX_NODE_WIDTH, MAX_NOTE_NODE_WIDTH, @@ -287,6 +287,7 @@ function renderNodeBody( config: NodeConfig | undefined, summary: string, updateConfig: (id: string, patch: Partial) => void, + localProviderNames: Set, ): ReactElement { if (config?.kind === "markdown_note") { return ; @@ -300,7 +301,13 @@ function renderNodeBody( return ; } if (config.kind === "model_provider" || config.kind === "model_config") { - return ; + return ( + + ); } if (config.kind === "llm") { return ; @@ -355,6 +362,16 @@ function RecipeGraphNodeBase({ const config = useRecipeStudioStore((state) => state.configs[id]); const openConfig = useRecipeStudioStore((state) => state.openConfig); const updateConfig = useRecipeStudioStore((state) => state.updateConfig); + const allConfigs = useRecipeStudioStore((state) => state.configs); + const localProviderNames = useMemo(() => { + const names = new Set(); + for (const cfg of Object.values(allConfigs)) { + if (cfg.kind === "model_provider" && cfg.is_local === true) { + names.add(cfg.name); + } + } + return names; + }, [allConfigs]); const llmAuxVisible = useRecipeStudioStore( (state) => state.llmAuxVisibility[id] ?? false, ); @@ -418,7 +435,12 @@ function RecipeGraphNodeBase({ data.kind === "tool_config" || data.kind === "validator"; const summary = getConfigSummary(config); - const nodeBody = renderNodeBody(config, summary, updateConfig); + const nodeBody = renderNodeBody( + config, + summary, + updateConfig, + localProviderNames, + ); const canShowLlmAux = config?.kind === "llm" && (Boolean(config.prompt.trim()) || diff --git a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx index 6457993c35..c62169956d 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/config-dialog.tsx @@ -18,6 +18,7 @@ type ConfigDialogProps = { categoryOptions: SamplerConfig[]; modelConfigAliases: string[]; modelProviderOptions: string[]; + localProviderNames: Set; toolProfileAliases: string[]; datetimeOptions: string[]; onUpdate: (id: string, patch: Partial) => void; @@ -32,6 +33,7 @@ export function ConfigDialog({ categoryOptions, modelConfigAliases, modelProviderOptions, + localProviderNames, toolProfileAliases, datetimeOptions, onUpdate, @@ -101,6 +103,7 @@ export function ConfigDialog({ categoryOptions, modelConfigAliases, modelProviderOptions, + localProviderNames, toolProfileAliases, datetimeOptions, onUpdate, diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx index 3192dc8fd9..368ae08acb 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/models/model-config-dialog.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui/combobox"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { type ReactElement, useRef, useState } from "react"; +import { type ReactElement, useEffect, useRef, useState } from "react"; import type { ModelConfig } from "../../types"; import { CollapsibleSectionTriggerButton } from "../shared/collapsible-section-trigger"; import { FieldLabel } from "../shared/field-label"; @@ -26,14 +26,17 @@ import { NameField } from "../shared/name-field"; type ModelConfigDialogProps = { config: ModelConfig; providerOptions: string[]; + localProviderNames: Set; onUpdate: (patch: Partial) => 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`; @@ -44,11 +47,13 @@ export function ModelConfigDialog({ const extraBodyId = `${config.id}-inference-extra-body`; const providerAnchorRef = useRef(null); const providerInputRef = useRef(config.provider); - const lastProviderRef = useRef(config.provider); - if (lastProviderRef.current !== config.provider) { - lastProviderRef.current = config.provider; + // Sync providerInputRef with the current provider value. Updating a ref in + // an effect (vs reading/writing it during render) satisfies the + // react-hooks/refs rule and keeps the combobox blur path stable across + // re-renders. + useEffect(() => { providerInputRef.current = config.provider; - } + }, [config.provider]); const updateField = ( key: K, value: ModelConfig[K], @@ -56,6 +61,21 @@ export function ModelConfigDialog({ onUpdate({ [key]: value } as Partial); }; + // Apply provider selection while keeping the local-provider model autofill + // consistent across both dropdown selection and free-typed + blur input. + const applyProviderChange = (selectedProvider: string) => { + const isLocal = localProviderNames.has(selectedProvider); + if (isLocal && !config.model.trim()) { + onUpdate({ provider: selectedProvider, model: "local" }); + return; + } + if (!isLocal && config.model === "local") { + onUpdate({ provider: selectedProvider, model: "" }); + return; + } + updateField("provider", selectedProvider); + }; + return (
updateField("provider", value ?? "")} + onValueChange={(value) => applyProviderChange(value ?? "")} onInputValueChange={(value) => { providerInputRef.current = value; }} @@ -98,7 +118,7 @@ export function ModelConfigDialog({ onBlur={() => { const next = providerInputRef.current; if (next !== config.provider) { - updateField("provider", next); + applyProviderChange(next); } }} /> @@ -124,12 +144,12 @@ export function ModelConfigDialog({ updateField("model", event.target.value)} /> diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx index 897fc7ee36..ef7366a225 100644 --- a/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx +++ b/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx @@ -24,6 +24,7 @@ export function ModelProviderDialog({ onUpdate, }: ModelProviderDialogProps): ReactElement { const [optionalOpen, setOptionalOpen] = useState(false); + const isLocal = config.is_local ?? false; const endpointId = `${config.id}-endpoint`; const apiKeyEnvId = `${config.id}-api-key-env`; const apiKeyId = `${config.id}-api-key`; @@ -43,94 +44,163 @@ export function ModelProviderDialog({ value={config.name} onChange={(value) => onUpdate({ name: value })} /> -
-

- Start with the endpoint you want this model to use -

-

- Most connections only need an endpoint. Add an API key if that - service requires one. -

-
+ + {/* Model source toggle */}
- - updateField("endpoint", event.target.value)} - /> +

Model source

+
+ + +
-
- - updateField("api_key", event.target.value)} - /> -
- - - - - + + {isLocal ? ( +
+

+ Ready to go +

+

+ Recipes will use whatever model is loaded in the Chat tab when you + hit run. No endpoint or API key needed. +

+
+ ) : ( + <> +
+

+ Start with the endpoint you want this model to use +

+

+ Most connections only need an endpoint. Add an API key if that + service requires one. +

+
updateField("api_key_env", event.target.value)} + placeholder="https://..." + value={config.endpoint} + onChange={(event) => updateField("endpoint", event.target.value)} />
-