Skip to content

Commit 2b71625

Browse files
committed
fix: preserve api key configuration on flow export
Made-with: Cursor
1 parent 699e356 commit 2b71625

7 files changed

Lines changed: 147 additions & 17 deletions

File tree

src/backend/base/langflow/api/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
parse_value,
3737
raise_error_if_astra_cloud_env,
3838
remove_api_keys,
39+
replace_api_key_with_env_var_name,
3940
validate_is_component,
4041
verify_public_flow_and_get_user,
4142
)
@@ -75,6 +76,7 @@
7576
"parse_value",
7677
"raise_error_if_astra_cloud_env",
7778
"remove_api_keys",
79+
"replace_api_key_with_env_var_name",
7880
"validate_is_component",
7981
"verify_public_flow_and_get_user",
8082
]

src/backend/base/langflow/api/utils/core.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import re
34
import uuid
45
from ast import literal_eval
56
from datetime import timedelta
@@ -68,14 +69,61 @@ def has_api_terms(word: str):
6869
return "api" in word and ("key" in word or ("token" in word and "tokens" not in word))
6970

7071

72+
def _get_provider_from_template(template: dict) -> str | None:
73+
"""Return provider name from template's model field, if any."""
74+
model_field = template.get("model")
75+
if not isinstance(model_field, dict):
76+
return None
77+
raw = model_field.get("value")
78+
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
79+
return raw[0].get("provider")
80+
return None
81+
82+
83+
def _looks_like_variable_name(value: Any) -> bool:
84+
"""Return True if value looks like a variable name."""
85+
if not value or not isinstance(value, str) or not value.strip():
86+
return False
87+
return bool(re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", value.strip()))
88+
89+
90+
def replace_api_key_with_env_var_name(flow: dict) -> dict:
91+
"""Normalize api_key to a variable name when possible, never export raw keys."""
92+
for node in flow.get("data", {}).get("nodes", []):
93+
node_data = node.get("data")
94+
if not isinstance(node_data, dict):
95+
continue
96+
node_inner = node_data.get("node")
97+
if not isinstance(node_inner, dict):
98+
continue
99+
template = node_inner.get("template")
100+
if not isinstance(template, dict):
101+
continue
102+
for value in template.values():
103+
if (
104+
isinstance(value, dict)
105+
and value.get("name") == "api_key"
106+
and value.get("password")
107+
):
108+
current = value.get("value")
109+
if _looks_like_variable_name(current):
110+
break # keep user's custom variable name
111+
# raw secret or other string: clear it
112+
value["value"] = None
113+
break
114+
return flow
115+
116+
71117
def remove_api_keys(flow: dict):
72-
"""Remove api keys from flow data."""
118+
"""Clear secret values from flow data."""
119+
flow = replace_api_key_with_env_var_name(flow)
73120
for node in flow.get("data", {}).get("nodes", []):
74121
node_data = node.get("data").get("node")
75122
template = node_data.get("template")
76123
for value in template.values():
77124
if isinstance(value, dict) and "name" in value and has_api_terms(value["name"]) and value.get("password"):
78-
value["value"] = None
125+
if value.get("name") != "api_key":
126+
value["value"] = None
79127

80128
return flow
81129

src/backend/base/langflow/api/v1/flows.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@
2121
from sqlmodel import and_, col, select
2222
from sqlmodel.ext.asyncio.session import AsyncSession
2323

24-
from langflow.api.utils import CurrentActiveUser, DbSession, cascade_delete_flow, remove_api_keys, validate_is_component
24+
from langflow.api.utils import (
25+
CurrentActiveUser,
26+
DbSession,
27+
cascade_delete_flow,
28+
remove_api_keys,
29+
replace_api_key_with_env_var_name,
30+
validate_is_component,
31+
)
2532
from langflow.api.v1.schemas import FlowListCreate
2633
from langflow.helpers.user import get_user_by_flow_id_or_endpoint_name
2734
from langflow.initial_setup.constants import STARTER_FOLDER_NAME
@@ -480,6 +487,8 @@ async def update_flow(
480487
if flow.endpoint_name is None or flow.endpoint_name == "":
481488
update_data["endpoint_name"] = None
482489

490+
# Never persist raw api_key; use env var name so import resolves from env
491+
update_data = replace_api_key_with_env_var_name(update_data)
483492
if settings_service.settings.remove_api_keys:
484493
update_data = remove_api_keys(update_data)
485494

@@ -672,6 +681,8 @@ async def _update_existing_flow(
672681
if "folder_id" not in update_data or update_data.get("folder_id") is None:
673682
update_data.pop("folder_id", None)
674683

684+
# Never persist raw api_key; use env var name so import resolves from env
685+
update_data = replace_api_key_with_env_var_name(update_data)
675686
if settings_service.settings.remove_api_keys:
676687
update_data = remove_api_keys(update_data)
677688

src/frontend/src/modals/exportModal/index.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import {
1313
} from "../../constants/constants";
1414
import useAlertStore from "../../stores/alertStore";
1515
import { useDarkStore } from "../../stores/darkStore";
16-
import { downloadFlow, removeApiKeys } from "../../utils/reactflowUtils";
16+
import {
17+
downloadFlow,
18+
removeApiKeys,
19+
replaceApiKeyWithEnvVarName,
20+
} from "../../utils/reactflowUtils";
1721
import BaseModal from "../baseModal";
1822

1923
const ExportModal = forwardRef(
@@ -57,7 +61,9 @@ const ExportModal = forwardRef(
5761
setOpen={setOpen}
5862
onSubmit={async () => {
5963
try {
60-
const flowToExport: FlowType = {
64+
// TODO: Full-version export (embedding all versions) is planned as a follow-up feature.
65+
// For now, export only the current working version of the flow.
66+
let flowToExport: FlowType = {
6167
id: currentFlow!.id,
6268
data: currentFlow!.data!,
6369
description,
@@ -69,6 +75,9 @@ const ExportModal = forwardRef(
6975
locked,
7076
};
7177

78+
// Never export raw api_key; always use env var name so import resolves from env
79+
flowToExport = replaceApiKeyWithEnvVarName(flowToExport);
80+
7281
if (checked) {
7382
await downloadFlow(flowToExport, name!, description);
7483

src/frontend/src/utils/reactflowUtils.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -437,16 +437,36 @@ export function isValidConnection(
437437
return false;
438438
}
439439

440+
function looksLikeVariableName(value: unknown): boolean {
441+
if (typeof value !== "string" || !value.trim()) return false;
442+
return /^[A-Z][A-Z0-9_]*$/i.test(value.trim());
443+
}
444+
445+
export function replaceApiKeyWithEnvVarName(flow: FlowType): FlowType {
446+
const out = cloneDeep(flow);
447+
out.data!.nodes.forEach((node) => {
448+
if (node.type !== "genericNode") return;
449+
const template = node.data.node!.template;
450+
if (!template.api_key) return;
451+
const current = template.api_key.value;
452+
if (looksLikeVariableName(current)) return;
453+
template.api_key.value = "";
454+
});
455+
return out;
456+
}
457+
440458
export function removeApiKeys(flow: FlowType): FlowType {
441-
const cleanFLow = cloneDeep(flow);
442-
cleanFLow.data!.nodes.forEach((node) => {
459+
const cleanFlow = replaceApiKeyWithEnvVarName(cloneDeep(flow));
460+
cleanFlow.data!.nodes.forEach((node) => {
443461
if (node.type !== "genericNode") return;
444-
for (const key in node.data.node!.template) {
445-
const field = node.data.node!.template[key];
462+
const template = node.data.node!.template;
463+
for (const key in template) {
464+
const field = template[key];
446465

447-
// Remove password fields
448466
if (field.password) {
449-
field.value = "";
467+
if (key !== "api_key") {
468+
field.value = "";
469+
}
450470
}
451471

452472
// Handle MCP server configurations
@@ -455,12 +475,11 @@ export function removeApiKeys(flow: FlowType): FlowType {
455475
field.value &&
456476
typeof field.value === "object"
457477
) {
458-
// Type assertion is safe here as we've verified it's an object with runtime checks
459478
cleanMcpConfig(field.value as MCPServerValue);
460479
}
461480
}
462481
});
463-
return cleanFLow;
482+
return cleanFlow;
464483
}
465484

466485
export function updateTemplate(

src/lfx/src/lfx/base/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"LIVE_MODEL_PROVIDERS",
2727
"LCModelComponent",
2828
"apply_provider_variable_config_to_build_config",
29+
"get_api_key_variable_key_for_provider",
2930
"fetch_live_ollama_models",
3031
"fetch_live_watsonx_models",
3132
"get_live_models_for_provider",

src/lfx/src/lfx/base/models/unified_models.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import importlib
44
import os
5+
import re
56
from functools import lru_cache
67
from typing import TYPE_CHECKING, Any
78
from uuid import UUID
@@ -382,17 +383,56 @@ def get_unified_models_detailed(
382383
def get_api_key_for_provider(user_id: UUID | str | None, provider: str, api_key: str | None = None) -> str | None:
383384
"""Get API key from self.api_key or global variables.
384385
386+
When api_key is set to an environment variable name (e.g. ANTHROPIC_API_KEY),
387+
that name is resolved from os.environ or global variables so imported flows
388+
can reference credentials without storing the raw key.
389+
385390
Args:
386391
user_id: The user ID to look up global variables for
387392
provider: The provider name (e.g., "OpenAI", "Anthropic")
388-
api_key: An optional API key provided directly
393+
api_key: An optional API key provided directly, or an env var name to resolve
389394
390395
Returns:
391396
The API key if found, None otherwise
392397
"""
393-
# First check if user provided an API key directly
394-
if api_key:
395-
return api_key
398+
# Resolve variable name (canonical or custom e.g. MY_OPENAI_API_KEY) from env or global vars
399+
def _resolve_var_name(var_name: str) -> str | None:
400+
env_value = os.environ.get(var_name)
401+
if env_value and env_value.strip():
402+
return env_value.strip()
403+
if user_id and not (isinstance(user_id, str) and user_id == "None"):
404+
async def _get_by_var_name():
405+
async with session_scope() as session:
406+
variable_service = get_variable_service()
407+
if variable_service is None:
408+
return None
409+
try:
410+
return await variable_service.get_variable(
411+
user_id=UUID(user_id) if isinstance(user_id, str) else user_id,
412+
name=var_name,
413+
field="",
414+
session=session,
415+
)
416+
except ValueError:
417+
return None
418+
419+
value = run_until_complete(_get_by_var_name())
420+
if value and str(value).strip():
421+
return str(value).strip()
422+
return None
423+
424+
if api_key and api_key.strip():
425+
var_name = api_key.strip()
426+
# Names that look like env/global variables (e.g. MY_OPENAI_API_KEY): resolve from env/DB
427+
if var_name.replace("_", "").isalnum() and var_name[0].isalpha():
428+
resolved = _resolve_var_name(var_name)
429+
if resolved:
430+
return resolved
431+
# Unresolved variable name: don't use as literal key
432+
if re.match(r"^[A-Z][A-Z0-9_]*$", var_name):
433+
return None
434+
# Literal API key (e.g. sk-...)
435+
return var_name
396436

397437
# If no user_id or user_id is the string "None", we can't look up global variables
398438
if user_id is None or (isinstance(user_id, str) and user_id == "None"):

0 commit comments

Comments
 (0)