File size: 7,593 Bytes
6303ae6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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"


@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,
) -> 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,
    )