mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
* 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>
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""Resolve effective config by merging per-workflow model overrides onto global config."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
from api.schemas.user_configuration import UserConfiguration
|
|
from api.services.configuration.registry import (
|
|
REGISTRY,
|
|
ServiceType,
|
|
)
|
|
|
|
# Maps override key → (UserConfiguration field, ServiceType for registry lookup)
|
|
_SECTION_MAP: dict[str, ServiceType] = {
|
|
"llm": ServiceType.LLM,
|
|
"tts": ServiceType.TTS,
|
|
"stt": ServiceType.STT,
|
|
"realtime": ServiceType.REALTIME,
|
|
}
|
|
|
|
|
|
def _build_section_from_override(service_type: ServiceType, override: dict):
|
|
"""Construct a typed config object from a raw override dict using the registry."""
|
|
provider = override.get("provider")
|
|
if not provider:
|
|
return None
|
|
registry = REGISTRY.get(service_type, {})
|
|
config_cls = registry.get(provider)
|
|
if config_cls is None:
|
|
return None
|
|
return config_cls(**override)
|
|
|
|
|
|
_SECRET_FIELDS = ("api_key", "credentials", "aws_access_key", "aws_secret_key")
|
|
|
|
|
|
def enrich_overrides_with_api_keys(
|
|
model_overrides: dict,
|
|
user_config: UserConfiguration,
|
|
) -> dict:
|
|
"""Copy API keys from the global config into model_overrides where missing.
|
|
|
|
When a workflow override selects the same provider as the current global
|
|
config but omits the API key, the override becomes broken if the global
|
|
config later switches to a different provider. This function stamps the
|
|
global provider's API key (and other secret fields) into the override at
|
|
save time so the override is self-contained.
|
|
"""
|
|
result = copy.deepcopy(model_overrides)
|
|
for section_key in _SECTION_MAP:
|
|
if section_key not in result:
|
|
continue
|
|
override = result[section_key]
|
|
override_provider = override.get("provider")
|
|
if not override_provider:
|
|
continue
|
|
global_section = getattr(user_config, section_key, None)
|
|
if global_section is None:
|
|
continue
|
|
if getattr(global_section, "provider", None) != override_provider:
|
|
continue
|
|
for field in _SECRET_FIELDS:
|
|
if override.get(field):
|
|
continue
|
|
if field == "api_key" and hasattr(global_section, "get_all_api_keys"):
|
|
all_keys = global_section.get_all_api_keys()
|
|
if all_keys:
|
|
override[field] = all_keys[0] if len(all_keys) == 1 else all_keys
|
|
else:
|
|
global_value = getattr(global_section, field, None)
|
|
if global_value is not None:
|
|
override[field] = global_value
|
|
return result
|
|
|
|
|
|
def resolve_effective_config(
|
|
user_config: UserConfiguration,
|
|
model_overrides: dict | None,
|
|
) -> UserConfiguration:
|
|
"""Deep-merge workflow model_overrides onto global user config.
|
|
|
|
- If model_overrides is None or empty, returns a copy of user_config unchanged.
|
|
- For each section (llm, tts, stt, realtime), if the override contains that key:
|
|
- If the global section is None, construct a new config from the override.
|
|
- If the provider changes, construct a new config from the override.
|
|
- Otherwise, merge override fields onto the existing config (model_copy).
|
|
- is_realtime is a simple boolean override.
|
|
- Sections not in the override are inherited from global unchanged.
|
|
- The original user_config is never mutated.
|
|
"""
|
|
if not model_overrides:
|
|
return user_config.model_copy(deep=True)
|
|
|
|
effective = user_config.model_copy(deep=True)
|
|
|
|
# Handle is_realtime boolean
|
|
if "is_realtime" in model_overrides:
|
|
effective.is_realtime = model_overrides["is_realtime"]
|
|
|
|
# Handle service sections
|
|
for section_key, service_type in _SECTION_MAP.items():
|
|
if section_key not in model_overrides:
|
|
continue
|
|
|
|
override = model_overrides[section_key]
|
|
base = getattr(effective, section_key)
|
|
|
|
if base is None:
|
|
# No global config for this section — build from override
|
|
setattr(
|
|
effective,
|
|
section_key,
|
|
_build_section_from_override(service_type, override),
|
|
)
|
|
elif "provider" in override and override["provider"] != base.provider:
|
|
# Provider changed — must construct new typed object
|
|
setattr(
|
|
effective,
|
|
section_key,
|
|
_build_section_from_override(service_type, override),
|
|
)
|
|
else:
|
|
# Same provider — merge fields onto existing config
|
|
merged = base.model_copy(update=override)
|
|
setattr(effective, section_key, merged)
|
|
|
|
return effective
|