Spaces:
Running
Running
| """API Gate. | |
| Validates configuration before downstream screens are enabled. Surfaces the | |
| precise status strings from the build plan. Resolves and validates the | |
| configured text provider — one of OpenAI, Anthropic, Groq, or Gemini — by | |
| checking provider support, API-key presence, and model presence. | |
| Configuration-level checks only: no expensive or live API calls are made here | |
| unless ``live=True`` is requested. A lightweight format-only placeholder check | |
| is applied to the Anthropic key specifically (``sk-ant-`` prefix); the other | |
| providers rely on the optional live reachability check. | |
| The API key value is never logged, displayed, or included in error output. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from app.config import Settings, get_settings | |
| _SUPPORTED_PROVIDERS = ("anthropic", "openai", "groq", "gemini") | |
| class ApiGateError(str): | |
| """Status strings returned by the gate. Mirrors the build plan exactly.""" | |
| NO_API_ADDED = "NO API ADDED" | |
| INVALID_API_KEY = "INVALID API KEY" | |
| API_QUOTA_EXCEEDED = "API QUOTA EXCEEDED" | |
| API_CONNECTION_FAILED = "API CONNECTION FAILED" | |
| VISION_MODEL_NOT_AVAILABLE = "VISION MODEL NOT AVAILABLE" | |
| MODEL_NOT_CONFIGURED = "MODEL NOT CONFIGURED" | |
| API_OK = "API OK" | |
| class CapabilityTestResult: | |
| status: str | |
| model: str | None = None | |
| provider: str | None = None | |
| def ok(self) -> bool: | |
| return self.status == ApiGateError.API_OK | |
| def error(self) -> str | None: | |
| return None if self.ok else self.status | |
| def _resolve_provider(provider: str | None, settings: Settings) -> str: | |
| raw = provider if provider is not None else settings.llm_provider | |
| return (raw or "").strip().lower() | |
| def _resolve_api_key(api_key: str | None, provider: str, settings: Settings) -> str | None: | |
| if api_key is not None: | |
| key = api_key.strip() | |
| return key or None | |
| if provider == "anthropic": | |
| return settings.anthropic_api_key | |
| if provider == "openai": | |
| return settings.openai_api_key | |
| if provider == "groq": | |
| return settings.groq_api_key | |
| if provider == "gemini": | |
| return settings.gemini_api_key | |
| return None | |
| def _resolve_model(model: str | None, provider: str, settings: Settings) -> str | None: | |
| if model is not None: | |
| m = model.strip() | |
| return m or None | |
| if provider == "anthropic": | |
| return settings.anthropic_model | |
| if provider == "openai": | |
| return settings.openai_model | |
| if provider == "groq": | |
| return settings.groq_model | |
| if provider == "gemini": | |
| return settings.gemini_model | |
| return None | |
| def _anthropic_key_format_ok(api_key: str) -> bool: | |
| """Cheap structural check — no network call. | |
| The Anthropic console issues keys prefixed with `sk-ant-`. This is a | |
| format sanity check only; it does not prove the key is live. | |
| """ | |
| key = api_key.strip() | |
| return key.startswith("sk-ant-") and len(key) >= 20 | |
| def run_capability_test( | |
| api_key: str | None = None, | |
| model: str | None = None, | |
| provider: str | None = None, | |
| settings: Settings | None = None, | |
| *, | |
| live: bool = False, | |
| ) -> CapabilityTestResult: | |
| """Run configuration-level checks against the resolved provider. | |
| Returns a CapabilityTestResult whose `status` is one of the build-plan | |
| status strings. Never raises on bad input; never echoes the API key. | |
| When ``live=True``, after the offline checks succeed, route through | |
| :func:`app.services.llm_client.call_text_llm` with task ``api_check`` | |
| and the prompt ``"Reply with exactly: API OK"``. The live response | |
| only has to be received — content matching is tolerant — so this | |
| works as a lightweight reachability check. | |
| """ | |
| settings = settings if settings is not None else get_settings() | |
| resolved_provider = _resolve_provider(provider, settings) | |
| if not resolved_provider: | |
| return CapabilityTestResult(status=ApiGateError.MODEL_NOT_CONFIGURED) | |
| if resolved_provider not in _SUPPORTED_PROVIDERS: | |
| return CapabilityTestResult( | |
| status=ApiGateError.MODEL_NOT_CONFIGURED, | |
| provider=resolved_provider, | |
| ) | |
| resolved_key = _resolve_api_key(api_key, resolved_provider, settings) | |
| if not resolved_key: | |
| return CapabilityTestResult( | |
| status=ApiGateError.NO_API_ADDED, | |
| provider=resolved_provider, | |
| ) | |
| resolved_model = _resolve_model(model, resolved_provider, settings) | |
| if not resolved_model: | |
| return CapabilityTestResult( | |
| status=ApiGateError.MODEL_NOT_CONFIGURED, | |
| provider=resolved_provider, | |
| ) | |
| if resolved_provider == "anthropic" and not _anthropic_key_format_ok(resolved_key): | |
| return CapabilityTestResult( | |
| status=ApiGateError.INVALID_API_KEY, | |
| provider=resolved_provider, | |
| model=resolved_model, | |
| ) | |
| if live: | |
| return _run_live_check( | |
| api_key=resolved_key, | |
| model=resolved_model, | |
| provider=resolved_provider, | |
| settings=settings, | |
| ) | |
| return CapabilityTestResult( | |
| status=ApiGateError.API_OK, | |
| provider=resolved_provider, | |
| model=resolved_model, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Live reachability check | |
| # --------------------------------------------------------------------------- | |
| _LIVE_STATUS_MAP: dict[str, str] = { | |
| # llm_client.STATUS_* → ApiGateError.* | |
| "ok": ApiGateError.API_OK, | |
| "no_api": ApiGateError.NO_API_ADDED, | |
| "invalid_key": ApiGateError.INVALID_API_KEY, | |
| "quota": ApiGateError.API_QUOTA_EXCEEDED, | |
| # A context-window overflow is a prompt-size problem, NOT a billing | |
| # quota; it must never be reported as "quota exceeded". For the tiny | |
| # api_check ping it also proves the key/endpoint work, so the gate | |
| # treats it as reachable. | |
| "context_overflow": ApiGateError.API_OK, | |
| "connection": ApiGateError.API_CONNECTION_FAILED, | |
| "model_missing": ApiGateError.MODEL_NOT_CONFIGURED, | |
| "unsupported_provider": ApiGateError.MODEL_NOT_CONFIGURED, | |
| # parse_error and generic exception both indicate the call reached | |
| # the provider but didn't come back clean — treat as connection issue. | |
| "parse_error": ApiGateError.API_CONNECTION_FAILED, | |
| # A truncated ping reply still proves the provider is reachable and the | |
| # key works (the api_check call only needs *a* response). | |
| "truncated": ApiGateError.API_OK, | |
| "exception": ApiGateError.API_CONNECTION_FAILED, | |
| "skipped": ApiGateError.API_CONNECTION_FAILED, | |
| } | |
| def _run_live_check( | |
| *, | |
| api_key: str, | |
| model: str, | |
| provider: str, | |
| settings: Settings, | |
| ) -> CapabilityTestResult: | |
| """Lightweight real-API check via the central llm_client.""" | |
| # Import lazily to avoid a circular import at module load. | |
| from app.services import llm_client | |
| result = llm_client.call_text_llm( | |
| task_name="api_check", | |
| system_prompt="", | |
| user_prompt="Reply with exactly: API OK", | |
| expected_json=False, | |
| max_tokens=16, | |
| settings=settings, | |
| ) | |
| if result.success: | |
| return CapabilityTestResult( | |
| status=ApiGateError.API_OK, | |
| provider=provider, | |
| model=model, | |
| ) | |
| mapped = _LIVE_STATUS_MAP.get(result.status, ApiGateError.API_CONNECTION_FAILED) | |
| return CapabilityTestResult( | |
| status=mapped, | |
| provider=provider, | |
| model=model, | |
| ) | |