feat: stamp API key into model override at save time to survive global provider change (#362)

* fix: stamp API key into model override at save time to survive global provider change

When a workflow overrides the TTS/LLM/STT provider to match the current
global config, the override dict only stores model/voice fields, not the
API key. If the global config later switches to a different provider, the
override can no longer inherit the API key and calls fail.

Fix: enrich_overrides_with_api_keys() copies the global provider's API
key (and other secret fields) into the override dict at workflow-save
time, making the override self-contained regardless of future global
config changes.

* feat: add test coverage and masking logic

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
nuthalapativarun 2026-05-27 01:31:14 -07:00 committed by GitHub
parent 8a58b0992d
commit 5b61ad645f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 451 additions and 39 deletions

View file

@ -21,10 +21,15 @@ from api.sdk_expose import sdk_expose
from api.services.auth.depends import get_user
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.masking import (
mask_workflow_configurations,
mask_workflow_definition,
merge_workflow_api_keys,
)
from api.services.configuration.resolve import resolve_effective_config
from api.services.configuration.merge import merge_workflow_configuration_secrets
from api.services.configuration.resolve import (
enrich_overrides_with_api_keys,
resolve_effective_config,
)
from api.services.mps_service_key_client import mps_service_key_client
from api.services.posthog_client import capture_event
from api.services.reports import generate_workflow_report_csv
@ -410,7 +415,9 @@ async def create_workflow(
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"workflow_configurations": mask_workflow_configurations(
workflow.workflow_configurations
),
}
@ -508,7 +515,9 @@ async def create_workflow_from_template(
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"workflow_configurations": mask_workflow_configurations(
workflow.workflow_configurations
),
}
except HTTPException:
@ -653,7 +662,7 @@ async def get_workflow(
"current_definition_id": workflow.current_definition_id,
"template_context_variables": template_vars,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow_configs,
"workflow_configurations": mask_workflow_configurations(workflow_configs),
"version_number": active_def.version_number if active_def else None,
"version_status": active_def.status if active_def else None,
"workflow_uuid": workflow.workflow_uuid,
@ -691,7 +700,9 @@ async def get_workflow_versions(
created_at=v.created_at,
published_at=v.published_at,
workflow_json=mask_workflow_definition(v.workflow_json),
workflow_configurations=v.workflow_configurations,
workflow_configurations=mask_workflow_configurations(
v.workflow_configurations
),
template_context_variables=v.template_context_variables,
)
for v in versions
@ -776,7 +787,9 @@ async def create_workflow_draft(
created_at=draft.created_at,
published_at=draft.published_at,
workflow_json=mask_workflow_definition(draft.workflow_json),
workflow_configurations=draft.workflow_configurations,
workflow_configurations=mask_workflow_configurations(
draft.workflow_configurations
),
template_context_variables=draft.template_context_variables,
)
@ -834,7 +847,9 @@ async def update_workflow_status(
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"workflow_configurations": mask_workflow_configurations(
workflow.workflow_configurations
),
"total_runs": run_count,
}
except ValueError as e:
@ -941,15 +956,34 @@ async def update_workflow(
# Validate model_overrides: resolve onto global config, then
# run the same validator used by the user-configurations endpoint.
if request.workflow_configurations and request.workflow_configurations.get(
"model_overrides"
):
# Also stamp the current global API key into the override so the override
# remains functional if the global config later switches to a different provider.
workflow_configurations = request.workflow_configurations
if workflow_configurations and workflow_configurations.get("model_overrides"):
existing_workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
if existing_workflow is None:
raise HTTPException(
status_code=404, detail=f"Workflow with id {workflow_id} not found"
)
existing_draft = await db_client.get_draft_version(workflow_id)
existing_configs = (
existing_draft.workflow_configurations
if existing_draft
else existing_workflow.released_definition.workflow_configurations
)
workflow_configurations = merge_workflow_configuration_secrets(
workflow_configurations,
existing_configs,
)
user_config = await db_client.get_user_configurations(user.id)
try:
effective = resolve_effective_config(
enriched_overrides = enrich_overrides_with_api_keys(
workflow_configurations["model_overrides"],
user_config,
request.workflow_configurations["model_overrides"],
)
effective = resolve_effective_config(user_config, enriched_overrides)
await UserConfigurationValidator().validate(
effective,
organization_id=user.selected_organization_id,
@ -957,6 +991,10 @@ async def update_workflow(
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
workflow_configurations = {
**workflow_configurations,
"model_overrides": enriched_overrides,
}
# Reject upfront if any new trigger path collides with another
# workflow's trigger — keeps the workflow record from
@ -979,7 +1017,7 @@ async def update_workflow(
name=request.name,
workflow_definition=workflow_definition,
template_context_variables=request.template_context_variables,
workflow_configurations=request.workflow_configurations,
workflow_configurations=workflow_configurations,
organization_id=user.selected_organization_id,
)
@ -1015,7 +1053,7 @@ async def update_workflow(
"current_definition_id": workflow.current_definition_id,
"template_context_variables": template_vars,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow_configs,
"workflow_configurations": mask_workflow_configurations(workflow_configs),
"version_number": active_def.version_number if active_def else None,
"version_status": active_def.status if active_def else None,
}
@ -1062,7 +1100,9 @@ async def duplicate_workflow_endpoint(
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"workflow_configurations": mask_workflow_configurations(
workflow.workflow_configurations
),
}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@ -1345,7 +1385,9 @@ async def duplicate_workflow_template(
"current_definition_id": workflow.current_definition_id,
"template_context_variables": workflow.template_context_variables,
"call_disposition_codes": workflow.call_disposition_codes,
"workflow_configurations": workflow.workflow_configurations,
"workflow_configurations": mask_workflow_configurations(
workflow.workflow_configurations
),
}