github-actions[bot] commited on
Commit
f9f5d3e
·
1 Parent(s): 9dfc563

deploy: backend from 0d25f4f

Browse files
main.py CHANGED
@@ -60,7 +60,7 @@ except Exception:
60
 
61
  # STARTUP VALIDATION - Run before anything else to prevent restart loops
62
  try:
63
- from startup_validation import run_all_validations
64
  run_all_validations() # Exits with error if any critical check fails
65
  except ImportError as e:
66
  # If startup_validation module is not found, log warning but continue
@@ -565,8 +565,17 @@ async def app_lifespan(_app: FastAPI) -> AsyncIterator[None]:
565
  except Exception as exc:
566
  logger.error("RAG vectorstore warm-up failed: %s", exc)
567
 
 
 
 
 
 
 
 
 
568
  _warmup_inference_task = asyncio.create_task(_warmup_inference_client())
569
  _warmup_vectorstore_task = asyncio.create_task(_warmup_vectorstore())
 
570
 
571
  # FIX(502): Set a readiness flag so /health reports the true state without
572
  # triggering heavy init on every health-check ping from HF Spaces' proxy.
@@ -2325,12 +2334,27 @@ class TestingResetResponse(BaseModel):
2325
  # to decide whether to route traffic; a slow response or crash here causes 502.
2326
  @app.get("/health")
2327
  async def health_check():
 
 
 
 
 
 
 
 
 
 
2328
  return {
2329
  "status": "ready" if _backend_ready else "starting",
2330
  "space": "mathpulse-ai",
2331
  "firebase": _firebase_ready,
2332
  "chat_model": CHAT_MODEL,
2333
  "risk_model": RISK_MODEL,
 
 
 
 
 
2334
  }
2335
 
2336
 
 
60
 
61
  # STARTUP VALIDATION - Run before anything else to prevent restart loops
62
  try:
63
+ from startup_validation import run_all_validations, validate_deepseek_auth
64
  run_all_validations() # Exits with error if any critical check fails
65
  except ImportError as e:
66
  # If startup_validation module is not found, log warning but continue
 
565
  except Exception as exc:
566
  logger.error("RAG vectorstore warm-up failed: %s", exc)
567
 
568
+ async def _warmup_deepseek_auth() -> None:
569
+ auth_state = await asyncio.to_thread(validate_deepseek_auth, 5.0)
570
+ logger.warning(
571
+ "DeepSeek startup auth state: status=%s key=%s",
572
+ auth_state["status"],
573
+ auth_state["key_suffix"],
574
+ )
575
+
576
  _warmup_inference_task = asyncio.create_task(_warmup_inference_client())
577
  _warmup_vectorstore_task = asyncio.create_task(_warmup_vectorstore())
578
+ _warmup_deepseek_auth_task = asyncio.create_task(_warmup_deepseek_auth())
579
 
580
  # FIX(502): Set a readiness flag so /health reports the true state without
581
  # triggering heavy init on every health-check ping from HF Spaces' proxy.
 
2334
  # to decide whether to route traffic; a slow response or crash here causes 502.
2335
  @app.get("/health")
2336
  async def health_check():
2337
+ # Cached DeepSeek auth state is read dynamically (never imported by name):
2338
+ # the startup probe rebinds the holder, and this handler must see the
2339
+ # current object. Missing module or holder degrades to "unchecked".
2340
+ try:
2341
+ import startup_validation as _startup_validation_module
2342
+ cached_auth = (
2343
+ getattr(_startup_validation_module, "cached_deepseek_auth_state", None) or {}
2344
+ )
2345
+ except Exception:
2346
+ cached_auth = {}
2347
  return {
2348
  "status": "ready" if _backend_ready else "starting",
2349
  "space": "mathpulse-ai",
2350
  "firebase": _firebase_ready,
2351
  "chat_model": CHAT_MODEL,
2352
  "risk_model": RISK_MODEL,
2353
+ "deepseek": {
2354
+ "status": cached_auth.get("status", "unchecked"),
2355
+ "key_suffix": cached_auth.get("key_suffix", "****"),
2356
+ "checked_at": cached_auth.get("checked_at"),
2357
+ },
2358
  }
2359
 
2360
 
startup_validation.py CHANGED
@@ -11,7 +11,12 @@ that's visible in HF Space logs.
11
  import os
12
  import sys
13
  import logging
 
 
14
  from pathlib import Path
 
 
 
15
 
16
  try:
17
  from dotenv import load_dotenv
@@ -28,12 +33,104 @@ except Exception:
28
 
29
  logger = logging.getLogger("mathpulse.startup")
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  class StartupError(Exception):
33
  """Critical error during startup validation."""
34
  pass
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def validate_imports() -> None:
38
  """Verify all critical imports work. Use absolute imports."""
39
  logger.info("🔍 Validating Python imports...")
 
11
  import os
12
  import sys
13
  import logging
14
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError
15
+ from datetime import datetime, timezone
16
  from pathlib import Path
17
+ from typing import Literal, Optional, TypedDict
18
+
19
+ from openai import APIConnectionError, APIError, APITimeoutError
20
 
21
  try:
22
  from dotenv import load_dotenv
 
33
 
34
  logger = logging.getLogger("mathpulse.startup")
35
 
36
+ AuthProbeStatus = Literal["ok", "auth_failed", "connection_failed", "unchecked"]
37
+
38
+
39
+ class DeepSeekAuthState(TypedDict):
40
+ status: AuthProbeStatus
41
+ key_suffix: str
42
+ checked_at: Optional[str]
43
+
44
+
45
+ cached_deepseek_auth_state: DeepSeekAuthState = {
46
+ "status": "unchecked",
47
+ "key_suffix": "****",
48
+ "checked_at": None,
49
+ }
50
+
51
 
52
  class StartupError(Exception):
53
  """Critical error during startup validation."""
54
  pass
55
 
56
 
57
+ def compute_key_fingerprint(api_key: Optional[str] = None) -> str:
58
+ configured_key = api_key if api_key is not None else os.getenv("DEEPSEEK_API_KEY", "")
59
+ return f"****{configured_key[-4:]}" if configured_key else "****"
60
+
61
+
62
+ def _record_deepseek_auth_state(status: AuthProbeStatus, key_suffix: str) -> DeepSeekAuthState:
63
+ global cached_deepseek_auth_state
64
+ cached_deepseek_auth_state = {
65
+ "status": status,
66
+ "key_suffix": key_suffix,
67
+ "checked_at": datetime.now(timezone.utc).isoformat(),
68
+ }
69
+ return cached_deepseek_auth_state
70
+
71
+
72
+ def validate_deepseek_auth(timeout_s: float = 5.0) -> DeepSeekAuthState:
73
+ api_key = os.getenv("DEEPSEEK_API_KEY", "")
74
+ key_suffix = compute_key_fingerprint(api_key)
75
+ if not api_key:
76
+ logger.warning("DeepSeek startup auth probe: status=unchecked key=%s", key_suffix)
77
+ return _record_deepseek_auth_state("unchecked", key_suffix)
78
+
79
+ try:
80
+ from services import inference_client
81
+
82
+ executor = ThreadPoolExecutor(max_workers=1)
83
+ probe_future = executor.submit(
84
+ lambda: inference_client.get_deepseek_client().models.list()
85
+ )
86
+ try:
87
+ probe_future.result(timeout=max(timeout_s, 0.0))
88
+ finally:
89
+ executor.shutdown(wait=False, cancel_futures=True)
90
+ except APIError as exc:
91
+ if getattr(exc, "status_code", None) in (401, 403):
92
+ logger.warning(
93
+ "DeepSeek startup auth probe: status=auth_failed key=%s",
94
+ key_suffix,
95
+ )
96
+ return _record_deepseek_auth_state("auth_failed", key_suffix)
97
+ logger.warning(
98
+ "DeepSeek startup auth probe: status=connection_failed key=%s",
99
+ key_suffix,
100
+ )
101
+ return _record_deepseek_auth_state("connection_failed", key_suffix)
102
+ except (APIConnectionError, APITimeoutError, FutureTimeoutError, TimeoutError, OSError):
103
+ logger.warning(
104
+ "DeepSeek startup auth probe: status=connection_failed key=%s",
105
+ key_suffix,
106
+ )
107
+ return _record_deepseek_auth_state("connection_failed", key_suffix)
108
+ except ValueError:
109
+ logger.warning("DeepSeek startup auth probe: status=unchecked key=%s", key_suffix)
110
+ return _record_deepseek_auth_state("unchecked", key_suffix)
111
+ except Exception as exc:
112
+ if getattr(exc, "status_code", None) in (401, 403) or type(exc).__name__ == "InferenceAuthError":
113
+ logger.warning(
114
+ "DeepSeek startup auth probe: status=auth_failed key=%s",
115
+ key_suffix,
116
+ )
117
+ return _record_deepseek_auth_state("auth_failed", key_suffix)
118
+ if type(exc).__name__ == "InferenceConnectionError":
119
+ logger.warning(
120
+ "DeepSeek startup auth probe: status=connection_failed key=%s",
121
+ key_suffix,
122
+ )
123
+ return _record_deepseek_auth_state("connection_failed", key_suffix)
124
+ logger.warning(
125
+ "DeepSeek startup auth probe: status=connection_failed key=%s",
126
+ key_suffix,
127
+ )
128
+ return _record_deepseek_auth_state("connection_failed", key_suffix)
129
+
130
+ logger.info("DeepSeek startup auth probe: status=ok key=%s", key_suffix)
131
+ return _record_deepseek_auth_state("ok", key_suffix)
132
+
133
+
134
  def validate_imports() -> None:
135
  """Verify all critical imports work. Use absolute imports."""
136
  logger.info("🔍 Validating Python imports...")
tests/test_startup_auth_visibility.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for DeepSeek startup authentication validation and health visibility.
2
+
3
+ These tests define the contract for startup credential validation and health visibility
4
+ before production implementation exists (RED state).
5
+
6
+ Contract under test:
7
+ (a) validate_deepseek_auth(timeout_s=5.0) when DeepSeek returns HTTP 401:
8
+ - returns a status representation with status == 'auth_failed'
9
+ - logs/captures key suffix in '****last4' format
10
+ - ensures the full fake key string never appears in logs or output
11
+ (b) GET /health when injected with cached auth state:
12
+ - returns additive dictionary under 'deepseek' key: {status, key_suffix, checked_at}
13
+ - contains only masked key suffix (e.g. '****1234')
14
+ - ensures full fake key string never appears in response body
15
+ (c) validate_deepseek_auth(timeout_s=5.0) when DeepSeek returns HTTP 200 / successful probe:
16
+ - returns status == 'ok'
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import os
23
+ from unittest.mock import MagicMock, patch
24
+
25
+ import httpx
26
+ import openai
27
+ import pytest
28
+ from fastapi.testclient import TestClient
29
+
30
+ # Ensure test env vars match existing test harness conventions
31
+ os.environ["DEEPSEEK_API_KEY"] = "mock-key-for-testing"
32
+ os.environ["INFERENCE_MAX_RETRIES"] = "1"
33
+ os.environ["INFERENCE_BACKGROUND_MAX_RETRIES"] = "1"
34
+ os.environ["INFERENCE_INTERACTIVE_MAX_RETRIES"] = "1"
35
+ os.environ["INFERENCE_BACKOFF_SEC"] = "0"
36
+ os.environ["INFERENCE_BACKGROUND_BACKOFF_SEC"] = "0"
37
+
38
+ import main as main_module
39
+ from main import app
40
+
41
+ # Test client configured following existing backend test patterns
42
+ client = TestClient(app, headers={"Authorization": "Bearer mock_token_teacher_T"})
43
+
44
+ FAKE_DEEPSEEK_KEY = "sk-fake-test-key-1234"
45
+ EXPECTED_MASKED_SUFFIX = "****1234"
46
+
47
+
48
+ @pytest.fixture(autouse=True)
49
+ def _setup_common_mocks():
50
+ """Bypass Firebase token verification, time.sleep backoff, and curriculum retrieval."""
51
+ with patch.object(
52
+ main_module.firebase_auth,
53
+ "verify_id_token",
54
+ return_value={"uid": "teacher_T", "role": "teacher"},
55
+ ), patch(
56
+ "routes.rag_routes.retrieve_lesson_pdf_context",
57
+ return_value=([], "mock_file"),
58
+ ), patch(
59
+ "time.sleep",
60
+ return_value=None,
61
+ ):
62
+ yield
63
+
64
+
65
+ class TestStartupAuthVisibility:
66
+ """Contract tests for DeepSeek auth-visibility at startup and /health."""
67
+
68
+ def test_mocked_401_startup_auth_check_yields_auth_failed_and_redacts_key(
69
+ self, caplog: pytest.LogCaptureFixture
70
+ ):
71
+ """(a) Mocked-401 startup auth check yields 'auth_failed' with masked suffix and no raw key."""
72
+ from startup_validation import (
73
+ compute_key_fingerprint,
74
+ validate_deepseek_auth,
75
+ )
76
+
77
+ req = httpx.Request("POST", "https://api.deepseek.com")
78
+ api_error_401 = openai.APIError(
79
+ f"Authentication failed for key {EXPECTED_MASKED_SUFFIX}",
80
+ request=req,
81
+ body={"error": {"message": f"Invalid API key {EXPECTED_MASKED_SUFFIX}"}},
82
+ )
83
+ api_error_401.status_code = 401
84
+
85
+ mock_client = MagicMock()
86
+ mock_client.models.list.side_effect = api_error_401
87
+ mock_client.chat.completions.create.side_effect = api_error_401
88
+
89
+ fingerprint = compute_key_fingerprint(FAKE_DEEPSEEK_KEY)
90
+ assert EXPECTED_MASKED_SUFFIX in fingerprint
91
+ assert FAKE_DEEPSEEK_KEY not in fingerprint
92
+
93
+ caplog.clear()
94
+ with caplog.at_level(logging.DEBUG), patch.dict(
95
+ os.environ, {"DEEPSEEK_API_KEY": FAKE_DEEPSEEK_KEY}
96
+ ), patch(
97
+ "services.inference_client.get_deepseek_client",
98
+ return_value=mock_client,
99
+ ):
100
+ auth_status = validate_deepseek_auth(timeout_s=5.0)
101
+
102
+ status_value = (
103
+ auth_status.get("status")
104
+ if isinstance(auth_status, dict)
105
+ else getattr(auth_status, "status", None)
106
+ )
107
+ assert status_value == "auth_failed"
108
+
109
+ captured_logs = caplog.text
110
+ assert EXPECTED_MASKED_SUFFIX in captured_logs
111
+ assert FAKE_DEEPSEEK_KEY not in captured_logs
112
+
113
+ def test_health_endpoint_returns_additive_cached_deepseek_auth_state(self):
114
+ """(b) GET /health returns additive deepseek: {status, key_suffix, checked_at} with masked key."""
115
+ import startup_validation
116
+
117
+ cached_state_holder = getattr(
118
+ startup_validation, "cached_deepseek_auth_state"
119
+ )
120
+
121
+ injected_state = {
122
+ "status": "auth_failed",
123
+ "key_suffix": EXPECTED_MASKED_SUFFIX,
124
+ "checked_at": "2026-09-22T00:00:00Z",
125
+ }
126
+
127
+ with patch.object(
128
+ startup_validation,
129
+ "cached_deepseek_auth_state",
130
+ injected_state,
131
+ ), patch.dict(
132
+ os.environ, {"DEEPSEEK_API_KEY": FAKE_DEEPSEEK_KEY}
133
+ ):
134
+ response = client.get("/health")
135
+
136
+ assert response.status_code == 200
137
+ response_body = response.json()
138
+
139
+ assert "deepseek" in response_body
140
+ deepseek_info = response_body["deepseek"]
141
+ assert deepseek_info.get("status") == "auth_failed"
142
+ assert deepseek_info.get("key_suffix") == EXPECTED_MASKED_SUFFIX
143
+ assert "checked_at" in deepseek_info
144
+
145
+ raw_response_text = response.text
146
+ assert EXPECTED_MASKED_SUFFIX in raw_response_text
147
+ assert FAKE_DEEPSEEK_KEY not in raw_response_text
148
+
149
+ def test_mocked_ok_startup_auth_check_yields_ok_status(self):
150
+ """(c) Mocked-OK startup auth check yields status 'ok'."""
151
+ from startup_validation import validate_deepseek_auth
152
+
153
+ mock_client = MagicMock()
154
+ mock_client.models.list.return_value = MagicMock()
155
+ mock_client.chat.completions.create.return_value = MagicMock()
156
+
157
+ with patch.dict(
158
+ os.environ, {"DEEPSEEK_API_KEY": FAKE_DEEPSEEK_KEY}
159
+ ), patch(
160
+ "services.inference_client.get_deepseek_client",
161
+ return_value=mock_client,
162
+ ):
163
+ auth_status = validate_deepseek_auth(timeout_s=5.0)
164
+
165
+ status_value = (
166
+ auth_status.get("status")
167
+ if isinstance(auth_status, dict)
168
+ else getattr(auth_status, "status", None)
169
+ )
170
+ assert status_value == "ok"