mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
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:
parent
8a58b0992d
commit
5b61ad645f
6 changed files with 451 additions and 39 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue