File size: 13,972 Bytes
6303ae6
 
 
 
 
 
 
 
 
 
 
93411b2
 
 
 
 
6303ae6
 
 
 
 
93411b2
6303ae6
 
93411b2
6303ae6
 
 
 
 
93411b2
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
357ee36
 
 
6303ae6
 
 
 
357ee36
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93411b2
 
6303ae6
 
 
 
 
 
 
 
 
 
 
93411b2
 
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93411b2
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357ee36
6303ae6
 
 
 
 
 
 
357ee36
 
 
6303ae6
 
 
 
 
 
 
 
 
 
 
 
93411b2
 
 
 
 
 
 
 
 
 
 
 
6303ae6
 
 
 
 
 
93411b2
 
6303ae6
93411b2
 
 
 
 
 
6303ae6
 
 
93411b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6303ae6
93411b2
6303ae6
 
 
 
 
93411b2
6303ae6
 
 
 
 
 
 
 
357ee36
 
 
6303ae6
 
 
 
 
93411b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
"""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.

:func:`run_interview_capability_test` is the same machinery aimed at the
interview provider. It is deliberately a separate entry point so it can be
run when the user opens the interview and never at app start: the dossier
route must keep working with no interview key configured.

The API key value is never logged, displayed, or included in error output.
"""

from __future__ import annotations

import inspect
from dataclasses import dataclass

from app import config as app_config
from app.config import Settings, get_settings


_SUPPORTED_PROVIDERS = ("anthropic", "openai", "groq", "gemini")

# The interview leg is pinned to one provider. ``app.config`` owns the
# canonical value; the literal fallback keeps this module importable against
# a configuration that predates the constant.
INTERVIEW_PROVIDER: str = (
    str(getattr(app_config, "INTERVIEW_PROVIDER", "groq") or "").strip().lower()
    or "groq"
)


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"
    # The key works but may not use this model (HTTP 403): a plan, billing
    # or organisation-verification gate, which re-copying the key won't fix.
    ACCESS_DENIED = "API ACCESS DENIED"
    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"
    # A model IS set, but the provider did not accept it (retired or
    # misspelt name). Kept apart from MODEL_NOT_CONFIGURED, which means no
    # model was set at all.
    MODEL_NOT_ACCEPTED = "MODEL NOT ACCEPTED"
    API_OK = "API OK"


@dataclass(frozen=True)
class CapabilityTestResult:
    status: str
    model: str | None = None
    provider: str | None = None

    @property
    def ok(self) -> bool:
        return self.status == ApiGateError.API_OK

    @property
    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,
    route_provider: bool = False,
    task_name: str = "api_check",
) -> 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.

    ``route_provider`` aims that live ping at the resolved provider instead
    of whichever one Settings has selected. It defaults to False so the
    Setup gate keeps testing exactly what it tested before.
    """
    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,
            route_provider=route_provider,
            task_name=task_name,
        )

    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,
    "access_denied": ApiGateError.ACCESS_DENIED,
    "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,
    # The ping went out with the model that is set and the provider turned
    # it down (404 / model_not_found) β€” not the same as no model being set.
    "model_missing": ApiGateError.MODEL_NOT_ACCEPTED,
    "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 _supported_call_overrides() -> frozenset[str]:
    """Per-call override arguments ``call_text_llm`` accepts."""
    # Import lazily to avoid a circular import at module load.
    from app.services import llm_client

    try:
        params = inspect.signature(llm_client.call_text_llm).parameters
    except (TypeError, ValueError):  # defensive: never break the gate
        return frozenset()
    return frozenset({"provider", "model"} & set(params))


def _run_live_check(
    *,
    api_key: str,
    model: str,
    provider: str,
    settings: Settings,
    route_provider: bool = False,
    task_name: str = "api_check",
) -> CapabilityTestResult:
    """Lightweight real-API check via the central llm_client.

    With ``route_provider`` the ping is sent to ``provider`` rather than the
    one selected in Settings, so a check for one provider can run while a
    different one stays configured for the rest of the app.
    """
    # Import lazily to avoid a circular import at module load.
    from app.services import llm_client

    extra: dict[str, str] = {}
    if route_provider:
        overrides = _supported_call_overrides()
        if "provider" not in overrides:
            # Without per-call routing the ping would test the configured
            # provider and the answer would be reported as if it came from
            # this one β€” a rejected key elsewhere would wrongly block this
            # provider. The offline checks above stand on their own.
            return CapabilityTestResult(
                status=ApiGateError.API_OK,
                provider=provider,
                model=model,
            )
        extra["provider"] = provider
        if "model" in overrides:
            # Ping the exact model this check reports on, so a retired or
            # misspelt name surfaces here rather than mid-flow.
            extra["model"] = model

    result = llm_client.call_text_llm(
        task_name=task_name,
        system_prompt="",
        user_prompt="Reply with exactly: API OK",
        expected_json=False,
        max_tokens=16,
        settings=settings,
        **extra,
    )
    if result.success:
        return CapabilityTestResult(
            status=ApiGateError.API_OK,
            provider=provider,
            model=model,
        )
    mapped = _LIVE_STATUS_MAP.get(result.status, ApiGateError.API_CONNECTION_FAILED)
    if result.status == "model_missing" and not getattr(result, "used_api", True):
        # The client found no model to send, so nothing was rejected.
        mapped = ApiGateError.MODEL_NOT_CONFIGURED
    return CapabilityTestResult(
        status=mapped,
        provider=provider,
        model=model,
    )


# ---------------------------------------------------------------------------
# Interview provider check
# ---------------------------------------------------------------------------


def _resolve_interview_model(model: str | None, settings: Settings) -> str | None:
    """Return the model the interview will use.

    The dedicated ``interview_model`` setting wins; otherwise the interview
    provider's own configured model is used.
    """
    if model is not None:
        stripped = model.strip()
        return stripped or None
    configured = getattr(settings, "interview_model", None)
    if isinstance(configured, str) and configured.strip():
        return configured.strip()
    return _resolve_model(None, INTERVIEW_PROVIDER, settings)


def run_interview_capability_test(
    api_key: str | None = None,
    model: str | None = None,
    settings: Settings | None = None,
    *,
    live: bool = False,
    prefer: str | None = None,
) -> CapabilityTestResult:
    """Check the interview provider, for use on entry to the interview.

    Kept apart from :func:`run_capability_test`, which gates the whole app
    at Setup. This one must never run at app start: a dossier user with no
    interview key configured has to reach every other screen unaffected.

    The verdict uses the same vocabulary as the Setup gate, so the two
    failures that read alike to a user stay apart β€” an exhausted free tier
    comes back as ``API_QUOTA_EXCEEDED`` while a rejected key comes back as
    ``INVALID_API_KEY``. With ``live=False`` (the default) only the offline
    checks run: provider supported, key present, model set.

    ``api_key`` overrides the offline key-presence check only; as on the
    Setup check, the live ping resolves the key from Settings.
    """
    settings = settings if settings is not None else get_settings()
    # Probe whatever the interview will actually run on: a Groq key if one
    # is set, otherwise the provider from Setup (see
    # ``llm_client.interview_route``). With neither, the Groq slot is
    # checked, which reports the missing key.
    from app.services import llm_client  # lazy: avoids an import cycle

    route = llm_client.interview_route(settings, prefer=prefer)
    if route is None:
        provider = INTERVIEW_PROVIDER
        resolved_model = _resolve_interview_model(model, settings)
    else:
        provider, route_model = route
        explicit = (model or "").strip() if model is not None else ""
        resolved_model = (
            explicit or route_model or _resolve_model(None, provider, settings)
        )
    return run_capability_test(
        api_key=api_key,
        model=resolved_model,
        provider=provider,
        settings=settings,
        live=live,
        route_provider=True,
        # Logged apart from the Setup check, so a failed interview key is
        # never reported as a problem with the main API.
        task_name="interview_check",
    )