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

@ -10,6 +10,11 @@ Module under test: api.services.configuration.resolve
import pytest
from api.schemas.user_configuration import UserConfiguration
from api.services.configuration.masking import (
contains_masked_key,
mask_workflow_configurations,
)
from api.services.configuration.merge import merge_workflow_configuration_secrets
from api.services.configuration.registry import (
DeepgramSTTConfiguration,
ElevenlabsTTSConfiguration,
@ -19,7 +24,10 @@ from api.services.configuration.registry import (
OpenAILLMService,
UltravoxRealtimeLLMConfiguration,
)
from api.services.configuration.resolve import resolve_effective_config
from api.services.configuration.resolve import (
enrich_overrides_with_api_keys,
resolve_effective_config,
)
# ---------------------------------------------------------------------------
# Fixtures
@ -403,3 +411,209 @@ class TestUnknownKeys:
{"embeddings": {"provider": "openai", "model": "text-embedding-3-small"}},
)
assert result.embeddings is None # was None in global, stays None
# ---------------------------------------------------------------------------
# enrich_overrides_with_api_keys
# ---------------------------------------------------------------------------
class TestEnrichOverridesWithApiKeys:
def test_injects_api_key_when_same_provider(self, global_config):
"""Override matching the global provider gets the global API key stamped in."""
overrides = {
"tts": {
"provider": "elevenlabs",
"voice": "Bella",
"model": "eleven_flash_v2_5",
}
}
enriched = enrich_overrides_with_api_keys(overrides, global_config)
assert enriched["tts"]["api_key"] == "el-global-tts"
def test_injects_all_api_keys_when_global_has_multiple(self, global_config):
"""Override matching a multi-key global provider gets every global key."""
global_config.llm.api_key = ["sk-global-1", "sk-global-2"]
overrides = {"llm": {"provider": "openai", "model": "gpt-4.1-mini"}}
enriched = enrich_overrides_with_api_keys(overrides, global_config)
assert enriched["llm"]["api_key"] == ["sk-global-1", "sk-global-2"]
def test_does_not_overwrite_existing_api_key(self, global_config):
"""Override that already has an api_key keeps its own key."""
overrides = {
"tts": {
"provider": "elevenlabs",
"api_key": "my-own-key",
"voice": "Bella",
"model": "eleven_flash_v2_5",
}
}
enriched = enrich_overrides_with_api_keys(overrides, global_config)
assert enriched["tts"]["api_key"] == "my-own-key"
def test_skips_when_provider_differs(self, global_config):
"""Override for a different provider is not enriched with the global key."""
overrides = {
"tts": {"provider": "cartesia", "voice": "some-voice", "model": "sonic-3"}
}
enriched = enrich_overrides_with_api_keys(overrides, global_config)
assert "api_key" not in enriched["tts"]
def test_does_not_mutate_original(self, global_config):
"""The input overrides dict must not be modified."""
overrides = {
"tts": {
"provider": "elevenlabs",
"voice": "Bella",
"model": "eleven_flash_v2_5",
}
}
original_copy = {
"tts": {
"provider": "elevenlabs",
"voice": "Bella",
"model": "eleven_flash_v2_5",
}
}
enrich_overrides_with_api_keys(overrides, global_config)
assert overrides == original_copy
def test_regression_override_survives_global_provider_change(self, global_config):
"""Core bug: override for provider A still works after global switches to B.
Steps:
1. Global TTS = ElevenLabs, Override TTS = ElevenLabs (different voice)
2. enrich_overrides_with_api_keys stamps ElevenLabs API key into override
3. Global TTS changes to Deepgram (simulate by building a new config)
4. resolve_effective_config must still return a valid ElevenLabs config
"""
override_at_save_time = {
"tts": {
"provider": "elevenlabs",
"voice": "Bella",
"model": "eleven_flash_v2_5",
}
}
enriched = enrich_overrides_with_api_keys(override_at_save_time, global_config)
assert enriched["tts"]["api_key"] == "el-global-tts"
# Simulate global config switching to Deepgram
from api.services.configuration.registry import DeepgramTTSConfiguration
new_global = global_config.model_copy(
update={
"tts": DeepgramTTSConfiguration(
provider="deepgram", api_key="dg-new", voice="aura-2-helena-en"
)
}
)
# The enriched override should resolve correctly against the new global
result = resolve_effective_config(new_global, enriched)
assert result.tts.provider == "elevenlabs"
assert result.tts.voice == "Bella"
assert result.tts.api_key == "el-global-tts"
class TestWorkflowConfigurationSecrets:
def test_masks_model_override_secrets(self):
configs = {
"model_overrides": {
"llm": {
"provider": "openai",
"api_key": "sk-real-llm-key",
"model": "gpt-4.1-mini",
},
"tts": {
"provider": "elevenlabs",
"api_key": "el-real-tts-key",
"voice": "Bella",
},
},
"ambient_noise_configuration": {"enabled": True},
}
masked = mask_workflow_configurations(configs)
assert masked["model_overrides"]["llm"]["api_key"] != "sk-real-llm-key"
assert contains_masked_key(masked["model_overrides"]["llm"]["api_key"])
assert masked["model_overrides"]["llm"]["api_key"].endswith("-key")
assert masked["model_overrides"]["tts"]["api_key"] != "el-real-tts-key"
assert masked["ambient_noise_configuration"] == {"enabled": True}
assert configs["model_overrides"]["llm"]["api_key"] == "sk-real-llm-key"
def test_restores_masked_model_override_secrets_from_existing_config(self):
existing = {
"model_overrides": {
"tts": {
"provider": "elevenlabs",
"api_key": "el-real-tts-key",
"voice": "Rachel",
}
}
}
incoming = mask_workflow_configurations(existing)
incoming["model_overrides"]["tts"]["voice"] = "Bella"
merged = merge_workflow_configuration_secrets(incoming, existing)
assert merged["model_overrides"]["tts"]["api_key"] == "el-real-tts-key"
assert merged["model_overrides"]["tts"]["voice"] == "Bella"
assert incoming["model_overrides"]["tts"]["api_key"] != "el-real-tts-key"
def test_single_masked_key_preserves_existing_multi_key_override(self):
existing = {
"model_overrides": {
"llm": {
"provider": "openai",
"api_key": ["sk-workflow-1", "sk-workflow-2"],
"model": "gpt-4.1-mini",
}
}
}
incoming = mask_workflow_configurations(existing)
incoming["model_overrides"]["llm"]["api_key"] = incoming["model_overrides"][
"llm"
]["api_key"][0]
merged = merge_workflow_configuration_secrets(incoming, existing)
assert merged["model_overrides"]["llm"]["api_key"] == [
"sk-workflow-1",
"sk-workflow-2",
]
def test_missing_secret_copies_current_global_key_instead_of_existing_workflow_key(
self, global_config
):
global_config.stt.api_key = ["dg-global-1", "dg-global-2"]
existing = {
"model_overrides": {
"stt": {
"provider": "deepgram",
"api_key": "dg-workflow-key",
"model": "nova-3-general",
"language": "multi",
}
}
}
incoming = {
"model_overrides": {
"stt": {
"provider": "deepgram",
"model": "nova-3-general",
"language": "en",
}
}
}
merged = merge_workflow_configuration_secrets(incoming, existing)
enriched = enrich_overrides_with_api_keys(
merged["model_overrides"],
global_config,
)
assert enriched["stt"]["api_key"] == ["dg-global-1", "dg-global-2"]
assert enriched["stt"]["language"] == "en"