fix: preserve api key configuration on flow export (#12129)

* fix: preserve api key configuration on flow export


* fix individual component's field

* [autofix.ci] apply automated fixes

* unhide var name

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* fetch relevant provider keys

* update starter projects

* update based on env var

* [autofix.ci] apply automated fixes

* fetch only env variables

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* update starter projects

* fix ruff errors

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* don't remove api keys if chosen by user

* remove redundant code

* [autofix.ci] apply automated fixes

* fix update build config

* remove api keys refactor

* only load values when exists in db

* modify other components

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* Template updates

* [autofix.ci] apply automated fixes

* Component index update

* Fix frontend test

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* preserve var names

* [autofix.ci] apply automated fixes

* update caution for saving api keys

---------

Co-authored-by: Hanzo Dev <[email protected]>
Co-authored-by: Eric Hare <[email protected]>
This commit is contained in:
Himavarsha
2026-03-12 16:27:36 +00:00
committed by GitHub
co-authored by Hanzo Dev Eric Hare
parent d7d9f55fd2
commit 20934c833f
45 changed files with 1105 additions and 538 deletions
@@ -68,6 +68,17 @@ def has_api_terms(word: str):
return "api" in word and ("key" in word or ("token" in word and "tokens" not in word))
def _get_provider_from_template(template: dict) -> str | None:
"""Return provider name from template's model field, if any."""
model_field = template.get("model")
if not isinstance(model_field, dict):
return None
raw = model_field.get("value")
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
return raw[0].get("provider")
return None
def remove_api_keys(flow: dict):
"""Remove api keys from flow data."""
for node in flow.get("data", {}).get("nodes", []):
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -20,15 +20,29 @@ const useFetchDataOnMount = (
useEffect(() => {
async function fetchData() {
const template = node.template[name];
if (
(template?.real_time_refresh ||
template?.refresh_button ||
(node.tool_mode && name === "tools_metadata")) &&
// options can be undefined but not an empty array
(template?.options?.length ?? 0) === 0
) {
if (!template) return;
const isRealtimeOrRefresh =
template.real_time_refresh ||
template.refresh_button ||
(node.tool_mode && name === "tools_metadata");
const hasOptions = (template.options?.length ?? 0) > 0;
const needApiKeyPrefill =
name === "model" &&
node.template?.api_key != null &&
!node.template?.api_key?.value;
const shouldFetchOnMount =
isRealtimeOrRefresh &&
(!hasOptions ||
(name === "api_key" && !template.value) ||
needApiKeyPrefill);
if (shouldFetchOnMount) {
mutateTemplate(
template?.value,
template.value,
nodeId,
node,
setNodeClass,
@@ -41,7 +55,7 @@ const useFetchDataOnMount = (
}
}
fetchData();
}, []); // Empty dependency array ensures that this effect runs only once, on mount
}, []);
};
export default useFetchDataOnMount;
@@ -7,6 +7,7 @@ import { CommandItem } from "../../../../ui/command";
import GlobalVariableModal from "../../../GlobalVariableModal/GlobalVariableModal";
import { getPlaceholder } from "../../helpers/get-placeholder-disabled";
import type { InputGlobalComponentType, InputProps } from "../../types";
import { looksLikeVariableName } from "../../../../../utils/reactflowUtils";
import InputComponent from "../inputComponent";
import {
useGlobalVariableValue,
@@ -114,9 +115,21 @@ export default function InputGlobalComponent({
/>
);
// // Extract options list for better readability
const variableOptions = typedGlobalVariables.map((variable) => variable.name);
const selectedOption = loadFromDb && valueExists ? currentValue : "";
let variableOptions = typedGlobalVariables.map((variable) => variable.name);
const isEnvVarName =
password && currentValue && looksLikeVariableName(currentValue);
if (
(loadFromDb &&
currentValue &&
!valueExists &&
!variableOptions.includes(currentValue)) ||
(isEnvVarName && !variableOptions.includes(currentValue))
) {
variableOptions = [...variableOptions, currentValue];
}
const selectedOption = loadFromDb || isEnvVarName ? currentValue : "";
if (!showParameter) {
return null;
+1 -1
View File
@@ -741,7 +741,7 @@ export const ZERO_NOTIFICATIONS = "No new notifications";
export const SUCCESS_BUILD = "Built successfully ✨";
export const ALERT_SAVE_WITH_API =
"Caution: Unchecking this box only removes API keys from fields specifically designated for API keys.";
"⚠️ Caution: Exporting this flow may expose sensitive credentials.";
export const SAVE_WITH_API_CHECKBOX = "Save with my API keys";
export const EDIT_TEXT_MODAL_TITLE = "Edit Text";
@@ -57,7 +57,7 @@ const ExportModal = forwardRef(
setOpen={setOpen}
onSubmit={async () => {
try {
const flowToExport: FlowType = {
let flowToExport: FlowType = {
id: currentFlow!.id,
data: currentFlow!.data!,
description,
@@ -0,0 +1,79 @@
import { removeApiKeys } from "../reactflowUtils";
describe("removeApiKeys", () => {
function makeFlow(template: Record<string, any>) {
return {
data: {
nodes: [
{
type: "genericNode",
data: {
node: {
template,
},
},
},
],
edges: [],
},
} as any;
}
it("preserves api_key when it is an env/global variable name", () => {
const flow = makeFlow({
api_key: {
name: "api_key",
value: "OPENAI_API_KEY",
password: true,
load_from_db: true,
},
openai_api_key: {
name: "openai_api_key",
value: "sk-test-123",
password: true,
load_from_db: false,
},
});
const result = removeApiKeys(flow);
const template = result.data!.nodes[0].data.node!.template;
expect(template.api_key.value).toBe("OPENAI_API_KEY");
expect(template.api_key.load_from_db).toBe(true);
expect(template.openai_api_key.value).toBe("");
expect(template.openai_api_key.load_from_db).toBe(false);
});
it("clears api_key when it contains a raw secret", () => {
const flow = makeFlow({
api_key: {
name: "api_key",
value: "sk-secret-123",
password: true,
load_from_db: false,
},
});
const result = removeApiKeys(flow);
const template = result.data!.nodes[0].data.node!.template;
expect(template.api_key.value).toBe("");
expect(template.api_key.load_from_db).toBe(false);
});
it("preserves non-password fields", () => {
const flow = makeFlow({
regular_field: {
name: "regular_field",
value: "keep-me",
password: false,
},
});
const result = removeApiKeys(flow);
const template = result.data!.nodes[0].data.node!.template;
expect(template.regular_field.value).toBe("keep-me");
});
});
+22 -7
View File
@@ -437,16 +437,32 @@ export function isValidConnection(
return false;
}
export function looksLikeVariableName(value: unknown): boolean {
if (typeof value !== "string" || !value.trim()) return false;
return /^[A-Z][A-Z0-9_]*$/i.test(value.trim());
}
export function removeApiKeys(flow: FlowType): FlowType {
const cleanFLow = cloneDeep(flow);
cleanFLow.data!.nodes.forEach((node) => {
const cleanFlow = cloneDeep(flow);
cleanFlow.data!.nodes.forEach((node) => {
if (node.type !== "genericNode") return;
for (const key in node.data.node!.template) {
const field = node.data.node!.template[key];
const template = node.data.node!.template;
for (const key in template) {
const field = template[key];
// Remove password fields
if (field.password) {
// Preserve env/global variable names for api_key so imported flows
// can still resolve credentials, but strip any raw secrets.
if (
key === "api_key" &&
((typeof field.value === "string" &&
looksLikeVariableName(field.value)) ||
field.load_from_db === true)
) {
continue;
}
field.value = "";
field.load_from_db = false;
}
// Handle MCP server configurations
@@ -455,12 +471,11 @@ export function removeApiKeys(flow: FlowType): FlowType {
field.value &&
typeof field.value === "object"
) {
// Type assertion is safe here as we've verified it's an object with runtime checks
cleanMcpConfig(field.value as MCPServerValue);
}
}
});
return cleanFLow;
return cleanFlow;
}
export function updateTemplate(
+1 -1
View File
@@ -523,7 +523,7 @@
"show": true,
"title_case": false,
"type": "str",
"value": "dummy_api_key"
"value": ""
},
"code": {
"advanced": true,
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+86 -32
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import importlib
import os
import re
from functools import lru_cache
from typing import TYPE_CHECKING, Any
from uuid import UUID
@@ -172,31 +173,36 @@ def get_provider_required_variable_keys(provider: str) -> list[str]:
return [v["variable_key"] for v in variables if v.get("required")]
def _get_all_provider_specific_field_names() -> set[str]:
"""Return set of all field names used as mapping_field by any provider."""
names: set[str] = set()
for meta in model_provider_metadata.values():
for v in meta.get("variables", []):
mapping = v.get("component_metadata", {}).get("mapping_field")
if mapping:
names.add(mapping)
return names
def apply_provider_variable_config_to_build_config(
build_config: dict,
provider: str,
) -> dict:
"""Apply provider variable metadata to component build config fields.
This function updates the build config fields based on the provider's variable metadata
stored in the `component_metadata` nested dict:
- Sets `required` based on `component_metadata.required`
- Sets `advanced` based on `component_metadata.advanced`
- Sets `info` based on `component_metadata.info`
- Sets `show` to True for fields that have a mapping_field for this provider
Args:
build_config: The component's build configuration dict
provider: The selected provider name (e.g., "OpenAI", "IBM WatsonX")
Returns:
Updated build_config dict
First hides all provider-specific fields (so switching e.g. IBM -> OpenAI
does not leave IBM fields visible), then shows and configures only the
current provider's fields.
"""
import os
provider_vars = get_provider_all_variables(provider)
all_provider_fields = _get_all_provider_specific_field_names()
for field_name in all_provider_fields:
if field_name in build_config:
build_config[field_name]["show"] = False
build_config[field_name]["required"] = False
# Build a lookup by component_metadata.mapping_field
provider_vars = get_provider_all_variables(provider)
vars_by_field = {}
for v in provider_vars:
component_meta = v.get("component_metadata", {})
@@ -224,23 +230,19 @@ def apply_provider_variable_config_to_build_config(
if info:
field_config["info"] = info
# Show the field since it's relevant to this provider
field_config["show"] = True
# If no value is set, try to get from environment variable
env_var_key = var_info.get("variable_key")
if env_var_key:
current_value = field_config.get("value")
# Only set from env if field is empty/None
if not current_value or (isinstance(current_value, str) and not current_value.strip()):
env_value = os.environ.get(env_var_key)
if env_value and env_value.strip():
field_config["value"] = env_value
logger.debug(
"Set field %s from environment variable %s",
field_name,
env_var_key,
)
env_value = os.environ.get(env_var_key)
if env_value and str(env_value).strip():
field_config["value"] = env_var_key
field_config["load_from_db"] = True
logger.debug(
"Set field %s to env var name %s (value resolved at runtime)",
field_name,
env_var_key,
)
return build_config
@@ -269,6 +271,17 @@ def get_model_providers() -> list[str]:
return sorted({md.get("provider", "Unknown") for group in MODELS_DETAILED for md in group})
def get_provider_for_model_name(model_name: str) -> str:
"""Return the provider for a model name by searching MODELS_DETAILED."""
if not model_name or not isinstance(model_name, str):
return ""
for group in MODELS_DETAILED:
for md in group:
if md.get("name") == model_name:
return md.get("provider", "") or ""
return ""
def get_unified_models_detailed(
providers: list[str] | None = None,
model_name: str | None = None,
@@ -382,17 +395,58 @@ def get_unified_models_detailed(
def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key: str | None = None) -> str | None:
"""Get API key from self.api_key or global variables.
When api_key is set to an environment variable name (e.g. ANTHROPIC_API_KEY),
that name is resolved from os.environ or global variables so imported flows
can reference credentials without storing the raw key.
Args:
user_id: The user ID to look up global variables for
provider: The provider name (e.g., "OpenAI", "Anthropic")
api_key: An optional API key provided directly
api_key: An optional API key provided directly, or an env var name to resolve
Returns:
The API key if found, None otherwise
"""
# First check if user provided an API key directly
if api_key:
return api_key
# Resolve variable name (canonical or custom e.g. MY_OPENAI_API_KEY) from env or global vars
def _resolve_var_name(var_name: str) -> str | None:
env_value = os.environ.get(var_name)
if env_value and env_value.strip():
return env_value.strip()
if user_id and not (isinstance(user_id, str) and user_id == "None"):
async def _get_by_var_name():
async with session_scope() as session:
variable_service = get_variable_service()
if variable_service is None:
return None
try:
return await variable_service.get_variable(
user_id=UUID(user_id) if isinstance(user_id, str) else user_id,
name=var_name,
field="",
session=session,
)
except ValueError:
return None
value = run_until_complete(_get_by_var_name())
if value and str(value).strip():
return str(value).strip()
return None
if api_key and api_key.strip():
var_name = api_key.strip()
# Names that look like env/global variables (e.g. MY_OPENAI_API_KEY): resolve from env/DB
if var_name.replace("_", "").isalnum() and var_name[0].isalpha():
resolved = _resolve_var_name(var_name)
if resolved:
return resolved
# Unresolved variable name: don't use as literal key
if re.match(r"^[A-Z][A-Z0-9_]*$", var_name):
return None
# Literal API key (e.g. sk-...)
return var_name
# If no user_id or user_id is the string "None", we can't look up global variables
if user_id is None or (isinstance(user_id, str) and user_id == "None"):
@@ -6,8 +6,10 @@ from collections.abc import Callable # noqa: TC003 - required at runtime for dy
from typing import Any
from lfx.base.models.unified_models import (
apply_provider_variable_config_to_build_config,
get_language_model_options,
get_llm,
get_provider_for_model_name,
update_model_options_in_build_config,
)
from lfx.custom.custom_component.component import Component
@@ -116,7 +118,7 @@ class LambdaFilterComponent(Component):
def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):
"""Dynamically update build config with user-filtered model options."""
return update_model_options_in_build_config(
build_config = update_model_options_in_build_config(
component=self,
build_config=build_config,
cache_key_prefix="language_model_options",
@@ -125,6 +127,19 @@ class LambdaFilterComponent(Component):
field_value=field_value,
)
current_model_value = field_value if field_name == "model" else build_config.get("model", {}).get("value")
provider = ""
if isinstance(current_model_value, list) and current_model_value:
selected_model = current_model_value[0]
provider = (selected_model.get("provider") or "").strip()
if not provider and selected_model.get("name"):
provider = get_provider_for_model_name(str(selected_model["name"]))
if provider:
build_config = apply_provider_variable_config_to_build_config(build_config, provider)
return build_config
def get_data_structure(self, data):
"""Extract the structure of data, replacing values with their types."""
if isinstance(data, list):
@@ -1,8 +1,10 @@
from typing import Any
from lfx.base.models.unified_models import (
apply_provider_variable_config_to_build_config,
get_language_model_options,
get_llm,
get_provider_for_model_name,
update_model_options_in_build_config,
)
from lfx.custom import Component
@@ -136,7 +138,7 @@ class SmartRouterComponent(Component):
def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):
"""Dynamically update build config with user-filtered model options."""
return update_model_options_in_build_config(
build_config = update_model_options_in_build_config(
component=self,
build_config=build_config,
cache_key_prefix="language_model_options",
@@ -145,6 +147,19 @@ class SmartRouterComponent(Component):
field_value=field_value,
)
current_model_value = field_value if field_name == "model" else build_config.get("model", {}).get("value")
provider = ""
if isinstance(current_model_value, list) and current_model_value:
selected_model = current_model_value[0]
provider = (selected_model.get("provider") or "").strip()
if not provider and selected_model.get("name"):
provider = get_provider_for_model_name(str(selected_model["name"]))
if provider:
build_config = apply_provider_variable_config_to_build_config(build_config, provider)
return build_config
def update_outputs(self, frontend_node: dict, field_name: str, field_value: Any) -> dict:
"""Create a dynamic output for each category in the categories table."""
if field_name in {"routes", "enable_else_output", "model"}:
@@ -17,6 +17,7 @@ from lfx.base.models.unified_models import (
apply_provider_variable_config_to_build_config,
get_language_model_options,
get_llm,
get_provider_for_model_name,
update_model_options_in_build_config,
)
from lfx.base.models.watsonx_constants import IBM_WATSONX_URLS
@@ -489,29 +490,21 @@ class AgentComponent(ToolCallingAgentComponent):
)
build_config = dotdict(build_config)
# Iterate over all providers in the MODEL_PROVIDERS_DICT
if field_name == "model":
# Update input types for all fields
build_config = self.update_input_types(build_config)
# Show/hide provider-specific fields based on selected model
# Get current model value - from field_value if model is being changed, otherwise from build_config
current_model_value = field_value if field_name == "model" else build_config.get("model", {}).get("value")
if isinstance(current_model_value, list) and len(current_model_value) > 0:
selected_model = current_model_value[0]
provider = selected_model.get("provider", "")
current_model_value = field_value if field_name == "model" else build_config.get("model", {}).get("value")
provider = ""
if isinstance(current_model_value, list) and current_model_value:
selected_model = current_model_value[0]
provider = (selected_model.get("provider") or "").strip()
if not provider and selected_model.get("name"):
provider = get_provider_for_model_name(str(selected_model["name"]))
# Hide provider-specific fields by default before applying provider config
for field in ["base_url_ibm_watsonx", "project_id"]:
if field in build_config:
build_config[field]["show"] = False
build_config[field]["required"] = False
if provider:
build_config = apply_provider_variable_config_to_build_config(build_config, provider)
# Apply provider variable configuration (advanced, required, info, env var fallback)
if provider:
build_config = apply_provider_variable_config_to_build_config(build_config, provider)
# Validate required keys
if field_name == "model":
default_keys = [
"code",
"_type",
@@ -3,9 +3,11 @@ from typing import Any
from lfx.base.embeddings.embeddings_class import EmbeddingsWithModels
from lfx.base.embeddings.model import LCEmbeddingsModel
from lfx.base.models.unified_models import (
apply_provider_variable_config_to_build_config,
get_api_key_for_provider,
get_embedding_class,
get_embedding_model_options,
get_provider_for_model_name,
get_unified_models_detailed,
update_model_options_in_build_config,
)
@@ -34,7 +36,6 @@ class EmbeddingModelComponent(LCEmbeddingsModel):
def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):
"""Dynamically update build config with user-filtered model options."""
# Update model options
build_config = update_model_options_in_build_config(
component=self,
build_config=build_config,
@@ -44,20 +45,23 @@ class EmbeddingModelComponent(LCEmbeddingsModel):
field_value=field_value,
)
# Show/hide provider-specific fields based on selected model
if field_name == "model" and isinstance(field_value, list) and len(field_value) > 0:
selected_model = field_value[0]
provider = selected_model.get("provider", "")
current_model_value = field_value if field_name == "model" else build_config.get("model", {}).get("value")
provider = ""
if isinstance(current_model_value, list) and current_model_value:
selected_model = current_model_value[0]
provider = (selected_model.get("provider") or "").strip()
if not provider and selected_model.get("name"):
provider = get_provider_for_model_name(str(selected_model["name"]))
# Show/hide watsonx fields
if provider:
build_config = apply_provider_variable_config_to_build_config(build_config, provider)
# Embedding-specific WatsonX toggles not covered by provider metadata
is_watsonx = provider == "IBM WatsonX"
build_config["base_url_ibm_watsonx"]["show"] = is_watsonx
build_config["project_id"]["show"] = is_watsonx
build_config["truncate_input_tokens"]["show"] = is_watsonx
build_config["input_text"]["show"] = is_watsonx
if is_watsonx:
build_config["base_url_ibm_watsonx"]["required"] = True
build_config["project_id"]["required"] = True
if "truncate_input_tokens" in build_config:
build_config["truncate_input_tokens"]["show"] = is_watsonx
if "input_text" in build_config:
build_config["input_text"]["show"] = is_watsonx
return build_config
@@ -3,6 +3,7 @@ from lfx.base.models.unified_models import (
apply_provider_variable_config_to_build_config,
get_language_model_options,
get_llm,
get_provider_for_model_name,
update_model_options_in_build_config,
)
from lfx.base.models.watsonx_constants import IBM_WATSONX_URLS
@@ -122,21 +123,15 @@ class LanguageModelComponent(LCModelComponent):
field_value=field_value,
)
# Hide all provider-specific fields by default
for field in ["api_key", "base_url_ibm_watsonx", "project_id", "ollama_base_url"]:
if field in build_config:
build_config[field]["show"] = False
build_config[field]["required"] = False
# Show/configure provider-specific fields based on selected model
# Get current model value - from field_value if model is being changed, otherwise from build_config
current_model_value = field_value if field_name == "model" else build_config.get("model", {}).get("value")
if isinstance(current_model_value, list) and len(current_model_value) > 0:
provider = ""
if isinstance(current_model_value, list) and current_model_value:
selected_model = current_model_value[0]
provider = selected_model.get("provider", "")
provider = (selected_model.get("provider") or "").strip()
if not provider and selected_model.get("name"):
provider = get_provider_for_model_name(str(selected_model["name"]))
if provider:
# Apply provider variable configuration (required_for_component, advanced, env var fallback)
build_config = apply_provider_variable_config_to_build_config(build_config, provider)
if provider:
build_config = apply_provider_variable_config_to_build_config(build_config, provider)
return build_config