diff --git a/haiku_rag_slim/haiku/rag/doctor.py b/haiku_rag_slim/haiku/rag/doctor.py index 14ef19fa..275b167e 100644 --- a/haiku_rag_slim/haiku/rag/doctor.py +++ b/haiku_rag_slim/haiku/rag/doctor.py @@ -83,25 +83,48 @@ def _sample(ids: list[str]) -> list[str]: return [*ids[:_SAMPLE_LIMIT], f"... (+{extra} more)"] -def _configured_providers(config: AppConfig) -> set[str]: - """Providers referenced by the current config across every model role.""" - providers = {config.embeddings.model.provider} - for model in ( - config.reranking.model, - config.qa.model, - config.analysis.model, - ): +def _active_models(config: AppConfig) -> list[tuple[str, str, str | None]]: + """(provider, name, base_url) for every model role the config activates. + + Picture-description and title models are only included when their feature + is enabled (``processing.pictures == "description"`` / ``auto_title``), so + doctor checks exactly the providers the next ingest will use. + """ + models = [ + ( + config.embeddings.model.provider, + config.embeddings.model.name, + config.embeddings.model.base_url, + ) + ] + for model in (config.reranking.model, config.qa.model, config.analysis.model): if model is not None: - providers.add(model.provider) - return providers + models.append((model.provider, model.name, model.base_url)) + + proc = config.processing + if proc.pictures == "description": + pd = proc.conversion_options.picture_description.model + models.append((pd.provider, pd.name, pd.base_url)) + if proc.auto_title: + tm = proc.title_model + models.append((tm.provider, tm.name, tm.base_url)) + return models def _check_api_keys(config: AppConfig, environ: dict[str, str]) -> CheckResult: - missing: list[str] = [] - for provider in sorted(_configured_providers(config)): - env_var = _PROVIDER_ENV_VARS.get(provider) - if env_var and not environ.get(env_var): - missing.append(f"{provider} ({env_var})") + # A custom base_url points at a self-hosted OpenAI-compatible endpoint that + # uses a placeholder key, so the SaaS key is only required when a provider + # is used without one. Reachability of custom endpoints is the probe's job. + need_key = { + provider + for provider, _name, base_url in _active_models(config) + if not base_url and provider in _PROVIDER_ENV_VARS + } + missing = [ + f"{provider} ({_PROVIDER_ENV_VARS[provider]})" + for provider in sorted(need_key) + if not environ.get(_PROVIDER_ENV_VARS[provider]) + ] if missing: return CheckResult( name="api_keys", @@ -607,14 +630,8 @@ def _provider_targets( {"kind": "docling-serve", "display": base, "models": set()}, ) - add_model( - config.embeddings.model.provider, - config.embeddings.model.name, - config.embeddings.model.base_url, - ) - for model in (config.reranking.model, config.qa.model, config.analysis.model): - if model is not None: - add_model(model.provider, model.name, model.base_url) + for provider, name, base_url in _active_models(config): + add_model(provider, name, base_url) return targets, local diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 1dd8f7cc..c1760f03 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -9,9 +9,12 @@ from typer.testing import CliRunner from haiku.rag.cli import _cli as cli from haiku.rag.config.models import ( AppConfig, + ConversionOptions, DoclingServeConfig, EmbeddingModelConfig, EmbeddingsConfig, + ModelConfig, + PictureDescriptionConfig, ProcessingConfig, ProvidersConfig, ) @@ -19,6 +22,8 @@ from haiku.rag.doctor import ( CheckResult, DoctorReport, Severity, + _active_models, + _check_api_keys, _check_embedding_drift, _check_vector_index, _model_present, @@ -628,6 +633,67 @@ def test_cli_doctor_exits_1_on_failure(monkeypatch): assert result.exit_code == 1 +# --- Active models / API keys --- + + +def test_api_key_not_required_for_custom_openai_base_url(): + config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig( + provider="openai", + name="x", + vector_dim=4, + base_url="http://localhost:1234/v1", + ) + ) + ) + assert _check_api_keys(config, {}).severity is Severity.OK + + +def test_api_key_required_for_openai_without_base_url(): + config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig(provider="openai", name="x", vector_dim=4) + ) + ) + result = _check_api_keys(config, {}) + assert result.severity is Severity.FAIL + assert any("OPENAI_API_KEY" in d for d in result.details) + + +def test_active_models_includes_picture_description_when_enabled(): + config = AppConfig(processing=ProcessingConfig(pictures="description")) + names = [name for _p, name, _b in _active_models(config)] + assert "ministral-3" in names + + +def test_active_models_excludes_picture_description_by_default(): + names = [name for _p, name, _b in _active_models(AppConfig())] + assert "ministral-3" not in names + + +def test_active_models_includes_title_model_when_auto_title(): + base = _active_models(AppConfig()) + with_title = _active_models(AppConfig(processing=ProcessingConfig(auto_title=True))) + assert len(with_title) == len(base) + 1 + + +def test_picture_description_model_checked_for_api_key(): + config = AppConfig( + processing=ProcessingConfig( + pictures="description", + conversion_options=ConversionOptions( + picture_description=PictureDescriptionConfig( + model=ModelConfig(provider="openai", name="gpt-4o") + ) + ), + ) + ) + result = _check_api_keys(config, {}) + assert result.severity is Severity.FAIL + assert any("OPENAI_API_KEY" in d for d in result.details) + + # --- Provider connectivity ---