feat: resolve watch engine and Gemini key in shared config

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bradautomates 2026-09-17 13:46:00 +10:00
parent 93822ce2bd
commit ccf43c5718
3 changed files with 72 additions and 1 deletions

View File

@ -13,6 +13,8 @@ CONFIG_FILE = CONFIG_DIR / '.env'
DEFAULT_DETAIL = 'balanced' DEFAULT_DETAIL = 'balanced'
DETAILS = {'transcript', 'efficient', 'balanced', 'token-burner'} DETAILS = {'transcript', 'efficient', 'balanced', 'token-burner'}
BACKENDS = {'auto', 'groq', 'openai', 'whisperx', 'none'} BACKENDS = {'auto', 'groq', 'openai', 'whisperx', 'none'}
ENGINES = {'auto', 'gemini', 'local'}
DEFAULT_GEMINI_MODEL = 'gemini-3.7-flash'
class ConfigError(ValueError): class ConfigError(ValueError):
@ -107,6 +109,26 @@ def load_api_key(preferred: str | None = None) -> tuple[str | None, str | None]:
return None, None return None, None
def load_gemini_key() -> str | None:
"""Environment, then the user config, then a project .env; never logged."""
value = os.environ.get('GEMINI_API_KEY', '').strip()
if not value:
for path in (CONFIG_FILE, Path.cwd() / '.env'):
value = read_env_file(path).get('GEMINI_API_KEY', '').strip()
if value:
break
return value or None
def resolve_engine(choice: str, has_key: bool) -> str:
if choice not in ENGINES:
raise ConfigError('WATCH_ENGINE must be auto, gemini, or local.')
if choice == 'gemini' and not has_key:
raise ConfigError('The gemini engine needs GEMINI_API_KEY in the environment or '
f'{CONFIG_FILE}. Add it, or rerun with --engine local.')
return 'gemini' if choice == 'gemini' or (choice == 'auto' and has_key) else 'local'
def get_config(*, backend_override: str | None = None) -> dict: def get_config(*, backend_override: str | None = None) -> dict:
file_values = read_env_file() file_values = read_env_file()
def setting(name, default=''): def setting(name, default=''):
@ -115,10 +137,16 @@ def get_config(*, backend_override: str | None = None) -> dict:
backend = backend_override if backend_override is not None else setting('WATCH_WHISPER_BACKEND', 'auto') backend = backend_override if backend_override is not None else setting('WATCH_WHISPER_BACKEND', 'auto')
if backend not in BACKENDS: if backend not in BACKENDS:
raise ConfigError('WATCH_WHISPER_BACKEND must be auto, groq, openai, whisperx, or none.') raise ConfigError('WATCH_WHISPER_BACKEND must be auto, groq, openai, whisperx, or none.')
engine = setting('WATCH_ENGINE', 'auto')
if engine not in ENGINES:
raise ConfigError('WATCH_ENGINE must be auto, gemini, or local.')
return { return {
'detail': detail if detail in DETAILS else DEFAULT_DETAIL, 'detail': detail if detail in DETAILS else DEFAULT_DETAIL,
'config_file': str(CONFIG_FILE), 'config_file': str(CONFIG_FILE),
'whisper_backend': backend, 'whisper_backend': backend,
'engine': engine,
'gemini_model': setting('WATCH_GEMINI_MODEL', DEFAULT_GEMINI_MODEL) or DEFAULT_GEMINI_MODEL,
'gemini_timeout': positive_number(setting('WATCH_GEMINI_TIMEOUT', '600'), 'WATCH_GEMINI_TIMEOUT'),
'sub_lang': setting('WATCH_SUB_LANG', 'auto'), 'sub_lang': setting('WATCH_SUB_LANG', 'auto'),
'cookies_file': setting('WATCH_COOKIES_FILE'), 'cookies_file': setting('WATCH_COOKIES_FILE'),
'cookies_from_browser': setting('WATCH_COOKIES_FROM_BROWSER'), 'cookies_from_browser': setting('WATCH_COOKIES_FROM_BROWSER'),

View File

@ -22,7 +22,7 @@ def isolated_user(monkeypatch, tmp_path):
cwd.mkdir() cwd.mkdir()
for key in list(os.environ): for key in list(os.environ):
if key.startswith("WATCH_") or key in { if key.startswith("WATCH_") or key in {
"GROQ_API_KEY", "OPENAI_API_KEY", "SETUP_COMPLETE", "XDG_CONFIG_HOME", "GROQ_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "SETUP_COMPLETE", "XDG_CONFIG_HOME",
}: }:
monkeypatch.delenv(key) monkeypatch.delenv(key)
monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("HOME", str(home))

View File

@ -74,3 +74,46 @@ def test_user_file_beats_project_file(monkeypatch):
assert config.load_api_key() == ('groq', 'user-test') assert config.load_api_key() == ('groq', 'user-test')
monkeypatch.setenv('GROQ_API_KEY', 'environment-test') monkeypatch.setenv('GROQ_API_KEY', 'environment-test')
assert config.load_api_key() == ('groq', 'environment-test') assert config.load_api_key() == ('groq', 'environment-test')
def test_engine_defaults_to_auto_and_local_without_key():
cfg = config.get_config()
assert cfg['engine'] == 'auto'
assert cfg['gemini_model'] == 'gemini-3.7-flash'
assert cfg['gemini_timeout'] == 600.0
assert config.load_gemini_key() is None
assert config.resolve_engine('auto', has_key=False) == 'local'
def test_auto_selects_gemini_when_key_present(monkeypatch):
monkeypatch.setenv('GEMINI_API_KEY', 'test-key')
assert config.load_gemini_key() == 'test-key'
assert config.resolve_engine('auto', has_key=True) == 'gemini'
assert config.resolve_engine('local', has_key=True) == 'local'
def test_gemini_key_lookup_order(monkeypatch, tmp_path):
config.CONFIG_DIR.mkdir(parents=True)
config.CONFIG_FILE.write_text('GEMINI_API_KEY=from-user-file\n')
(tmp_path / 'cwd' / '.env').write_text('GEMINI_API_KEY=from-cwd\n')
assert config.load_gemini_key() == 'from-user-file'
monkeypatch.setenv('GEMINI_API_KEY', 'from-env')
assert config.load_gemini_key() == 'from-env'
monkeypatch.delenv('GEMINI_API_KEY')
config.CONFIG_FILE.write_text('GEMINI_API_KEY=\n')
assert config.load_gemini_key() == 'from-cwd'
def test_explicit_gemini_without_key_is_a_safe_error():
with pytest.raises(config.ConfigError, match='GEMINI_API_KEY'):
config.resolve_engine('gemini', has_key=False)
def test_invalid_engine_and_timeout_rejected(monkeypatch):
monkeypatch.setenv('WATCH_ENGINE', 'vertex')
with pytest.raises(config.ConfigError, match='WATCH_ENGINE'):
config.get_config()
monkeypatch.setenv('WATCH_ENGINE', 'auto')
monkeypatch.setenv('WATCH_GEMINI_TIMEOUT', '-5')
with pytest.raises(config.ConfigError, match='WATCH_GEMINI_TIMEOUT'):
config.get_config()