diff --git a/skills/watch/scripts/config.py b/skills/watch/scripts/config.py index 313ac27..988cebe 100644 --- a/skills/watch/scripts/config.py +++ b/skills/watch/scripts/config.py @@ -13,6 +13,8 @@ CONFIG_FILE = CONFIG_DIR / '.env' DEFAULT_DETAIL = 'balanced' DETAILS = {'transcript', 'efficient', 'balanced', 'token-burner'} BACKENDS = {'auto', 'groq', 'openai', 'whisperx', 'none'} +ENGINES = {'auto', 'gemini', 'local'} +DEFAULT_GEMINI_MODEL = 'gemini-3.7-flash' class ConfigError(ValueError): @@ -107,6 +109,26 @@ def load_api_key(preferred: str | None = None) -> tuple[str | None, str | 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: file_values = read_env_file() 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') if backend not in BACKENDS: 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 { 'detail': detail if detail in DETAILS else DEFAULT_DETAIL, 'config_file': str(CONFIG_FILE), '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'), 'cookies_file': setting('WATCH_COOKIES_FILE'), 'cookies_from_browser': setting('WATCH_COOKIES_FROM_BROWSER'), diff --git a/tests/conftest.py b/tests/conftest.py index 4e69143..e93c196 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,7 @@ def isolated_user(monkeypatch, tmp_path): cwd.mkdir() for key in list(os.environ): 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.setenv("HOME", str(home)) diff --git a/tests/test_config.py b/tests/test_config.py index 0c6644a..7ec8bde 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -74,3 +74,46 @@ def test_user_file_beats_project_file(monkeypatch): assert config.load_api_key() == ('groq', 'user-test') monkeypatch.setenv('GROQ_API_KEY', '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()