diff --git a/apps/api/src/repopilot_api/access.py b/apps/api/src/repopilot_api/access.py index 21479c4..7960f3c 100644 --- a/apps/api/src/repopilot_api/access.py +++ b/apps/api/src/repopilot_api/access.py @@ -16,7 +16,7 @@ from repopilot_api.product_db import product_accounts, product_credentials, usage_events from repopilot_core.llm.provider import LLMProvider -from repopilot_core.settings import Settings +from repopilot_core.settings import Settings, get_settings CredentialSource = Literal["platform", "user"] @@ -58,13 +58,15 @@ class AccessService(Protocol): async def status(self, session_id: str) -> AccountUsage: ... async def connect_provider( - self, session_id: str, *, groq_api_key: str, huggingface_api_key: str | None + self, session_id: str, *, groq_api_key: str, huggingface_api_key: str ) -> AccountUsage: ... async def disconnect_provider(self, session_id: str) -> AccountUsage: ... async def provider_for(self, session_id: str) -> LLMProvider | None: ... + async def credentials_durable(self, session_id: str) -> bool: ... + async def reserve_repository(self, session_id: str, repo_id: str) -> UsageReservation: ... async def reserve_question(self, session_id: str, repo_id: str) -> UsageReservation: ... @@ -113,8 +115,13 @@ async def _account_key(self, session_id: str) -> tuple[str, str] | None: return None return str(row.provider), str(row.provider_account_id) - async def _stored_keys(self, session_id: str) -> tuple[str, str | None] | None: - """Decrypted (groq, hugging face) keys saved for this session's account.""" + async def _stored_keys(self, session_id: str) -> tuple[str, str] | None: + """Decrypted (groq, hugging face) keys saved for this session's account. + + Both are required. A row saved before Hugging Face became mandatory has + no HF key, and is reported as not connected so the reader reconnects — + indexing on the platform's own token is exactly what that row would do. + """ account = await self._account_key(session_id) if account is None: return None @@ -132,23 +139,24 @@ async def _stored_keys(self, session_id: str) -> tuple[str, str | None] | None: ).first() if row is None: return None + if not row.huggingface_api_key: + return None try: groq = self.cipher.decrypt(str(row.groq_api_key).encode()).decode() - stored_hf = row.huggingface_api_key - hf = self.cipher.decrypt(str(stored_hf).encode()).decode() if stored_hf else None + hf = self.cipher.decrypt(str(row.huggingface_api_key).encode()).decode() except InvalidToken: # Session secret rotated out from under the stored ciphertext. # Treat as not connected; the reader reconnects their key. return None return groq, hf - def _build_provider(self, groq: str, huggingface: str | None) -> LLMProvider: + def _build_provider(self, groq: str, huggingface: str) -> LLMProvider: user_settings = self.settings.model_copy( update={ "groq_api_key": groq, "cerebras_api_key": None, "huggingface_api_key": huggingface, - "llm_hf_chat_fallback": huggingface is not None, + "llm_hf_chat_fallback": True, } ) return LLMProvider.build(settings=user_settings) @@ -161,10 +169,9 @@ async def _resolve_provider(self, session_id: str) -> LLMProvider | None: stored = await self._stored_keys(session_id) if stored is None: return None - groq, huggingface = stored - provider = self._build_provider(groq, huggingface) + provider = self._build_provider(*stored) self.providers[session_id] = provider - self.connected[session_id] = (True, huggingface is not None) + self.connected[session_id] = (True, True) return provider async def _ensure_account(self, session_id: str) -> None: @@ -203,33 +210,32 @@ async def status(self, session_id: str) -> AccountUsage: ) async def connect_provider( - self, session_id: str, *, groq_api_key: str, huggingface_api_key: str | None + self, session_id: str, *, groq_api_key: str, huggingface_api_key: str ) -> AccountUsage: await self._ensure_account(session_id) clean_groq = groq_api_key.strip() - clean_hf = (huggingface_api_key or "").strip() or None + clean_hf = huggingface_api_key.strip() if len(clean_groq) < 12: raise ValueError("Enter a complete Groq API key.") - if clean_hf is not None and len(clean_hf) < 8: - raise ValueError("Enter a complete Hugging Face token or leave it blank.") + if len(clean_hf) < 8: + raise ValueError("Enter a complete Hugging Face token.") await self._validate_provider_key( provider="Groq", base_url=self.settings.groq_base_url, api_key=clean_groq, ) - if clean_hf is not None: - await self._validate_provider_key( - provider="Hugging Face", - base_url=self.settings.huggingface_base_url, - api_key=clean_hf, - ) + await self._validate_provider_key( + provider="Hugging Face", + base_url=self.settings.huggingface_base_url, + api_key=clean_hf, + ) previous = self.providers.pop(session_id, None) if previous is not None: await previous.aclose() self.providers[session_id] = self._build_provider(clean_groq, clean_hf) - self.connected[session_id] = (True, clean_hf is not None) + self.connected[session_id] = (True, True) # Signed in? Keep the key with the account so a sign-out doesn't drop # it. Anonymous sessions have nothing durable to attach it to. @@ -237,9 +243,7 @@ async def connect_provider( if account is not None: values = { "groq_api_key": self.cipher.encrypt(clean_groq.encode()).decode(), - "huggingface_api_key": ( - self.cipher.encrypt(clean_hf.encode()).decode() if clean_hf else None - ), + "huggingface_api_key": self.cipher.encrypt(clean_hf.encode()).decode(), } async with self.engine.begin() as conn: await conn.execute( @@ -292,6 +296,16 @@ async def disconnect_provider(self, session_id: str) -> AccountUsage: async def provider_for(self, session_id: str) -> LLMProvider | None: return await self._resolve_provider(session_id) + async def credentials_durable(self, session_id: str) -> bool: + """True when another process could rebuild this session's provider. + + Indexing runs on the reader's own keys, so it can only be handed to the + background worker when the worker can reach those keys — which means an + account-bound row in Postgres. An anonymous session's keys live in this + process's memory only, so its indexing stays here. + """ + return (await self._stored_keys(session_id)) is not None + async def _reserve( self, session_id: str, @@ -392,6 +406,7 @@ class InMemoryAccessService: repository_events: dict[str, set[str]] = field(default_factory=dict) question_counts: dict[str, int] = field(default_factory=dict) connected_sessions: dict[str, bool] = field(default_factory=dict) + providers: dict[str, LLMProvider] = field(default_factory=dict) async def status(self, session_id: str) -> AccountUsage: groq = session_id in self.connected_sessions @@ -405,19 +420,40 @@ async def status(self, session_id: str) -> AccountUsage: ) async def connect_provider( - self, session_id: str, *, groq_api_key: str, huggingface_api_key: str | None + self, session_id: str, *, groq_api_key: str, huggingface_api_key: str ) -> AccountUsage: if len(groq_api_key.strip()) < 12: raise ValueError("Enter a complete Groq API key.") - self.connected_sessions[session_id] = bool(huggingface_api_key) + if len(huggingface_api_key.strip()) < 8: + raise ValueError("Enter a complete Hugging Face token.") + self.connected_sessions[session_id] = True + # A connected session must hand back a provider, exactly as the real + # service does — every caller that runs on the reader's own keys + # (indexing above all) branches on that being present. + self.providers[session_id] = LLMProvider.build( + settings=get_settings().model_copy( + update={ + "groq_api_key": groq_api_key.strip(), + "cerebras_api_key": None, + "huggingface_api_key": huggingface_api_key.strip(), + "llm_hf_chat_fallback": True, + } + ) + ) return await self.status(session_id) async def disconnect_provider(self, session_id: str) -> AccountUsage: self.connected_sessions.pop(session_id, None) + provider = self.providers.pop(session_id, None) + if provider is not None: + await provider.aclose() return await self.status(session_id) async def provider_for(self, session_id: str) -> LLMProvider | None: - return None + return self.providers.get(session_id) + + async def credentials_durable(self, session_id: str) -> bool: + return False async def reserve_repository(self, session_id: str, repo_id: str) -> UsageReservation: repos = self.repository_events.setdefault(session_id, set()) diff --git a/apps/api/src/repopilot_api/app.py b/apps/api/src/repopilot_api/app.py index 858c9b4..c8ce9c2 100644 --- a/apps/api/src/repopilot_api/app.py +++ b/apps/api/src/repopilot_api/app.py @@ -168,7 +168,7 @@ def allowance_error(exc: AllowanceExceededError) -> HTTPException: "code": "PROVIDER_KEY_REQUIRED", "action": exc.action, "message": ( - "Your free repository is used. Connect your Groq key to analyze another." + "Connect your Groq key and Hugging Face token to analyze a repository." ), }, ) @@ -192,11 +192,7 @@ async def connect_provider( usage = await get_services().access.connect_provider( session_id, groq_api_key=request.groq_api_key.get_secret_value(), - huggingface_api_key=( - request.huggingface_api_key.get_secret_value() - if request.huggingface_api_key is not None - else None - ), + huggingface_api_key=request.huggingface_api_key.get_secret_value(), ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc @@ -217,19 +213,29 @@ async def create_repo( normalized_repo_id = repo_slug(request.repo_url) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc + # Indexing runs on the reader's own keys, never the platform's: cloning, + # chunking, summarising and embedding a repository is the expensive half + # of this product, and a deployment that pays for it out of its own quota + # cannot bound its cost or its rate limits. + provider = await get_services().access.provider_for(session_id) + if provider is None: + raise allowance_error(AllowanceExceededError("repository")) try: reservation = await get_services().access.reserve_repository( session_id, normalized_repo_id ) except AllowanceExceededError as exc: raise allowance_error(exc) from exc - provider = ( - await get_services().access.provider_for(session_id) - if reservation.source == "user" - else None - ) try: - record = await get_services().repos.enqueue(request.repo_url, provider=provider) + record = await get_services().repos.enqueue( + request.repo_url, + provider=provider, + session_id=( + session_id + if await get_services().access.credentials_durable(session_id) + else None + ), + ) except Exception: await get_services().access.release(reservation) raise diff --git a/apps/api/src/repopilot_api/jobs/index_repo.py b/apps/api/src/repopilot_api/jobs/index_repo.py index 5aaa75a..33343eb 100644 --- a/apps/api/src/repopilot_api/jobs/index_repo.py +++ b/apps/api/src/repopilot_api/jobs/index_repo.py @@ -13,8 +13,9 @@ import structlog from arq.connections import RedisSettings -from repopilot_core.llm.provider import LLMProvider +from repopilot_api.access import ProductAccessService from repopilot_core.settings import get_settings +from repopilot_ingestion.persist import make_engine from repopilot_ingestion.pipeline import PipelineResult from repopilot_ingestion.pipeline import index_repo as _index_repo @@ -33,21 +34,33 @@ def _redis_settings_from_url() -> RedisSettings: async def startup(ctx: dict[str, Any]) -> None: settings = get_settings() ctx["settings"] = settings - ctx["llm"] = LLMProvider.build(settings=settings) + # No platform-keyed provider here: indexing runs on the reader's own keys, + # resolved per job. A shared fallback provider would quietly put every + # unresolvable job back on the deployment's own quota. + ctx["access"] = ProductAccessService(engine=make_engine(settings), settings=settings) log.info("arq.startup") async def shutdown(ctx: dict[str, Any]) -> None: - llm: LLMProvider | None = ctx.get("llm") - if llm is not None: - await llm.aclose() + access: ProductAccessService | None = ctx.get("access") + if access is not None: + await access.aclose() log.info("arq.shutdown") -async def index_repo(ctx: dict[str, Any], repo_url: str) -> dict[str, Any]: - """arq job: index a GitHub repo end-to-end. Returns a JSON-able status dict.""" +async def index_repo(ctx: dict[str, Any], repo_url: str, session_id: str) -> dict[str, Any]: + """arq job: index a GitHub repo end-to-end. Returns a JSON-able status dict. + + ``session_id`` names whose keys to index with. The keys themselves stay in + the encrypted credential store and are read here, so the job payload in + Redis carries no secret. + """ + access: ProductAccessService = ctx["access"] + provider = await access.provider_for(session_id) + if provider is None: + raise RuntimeError("indexing requires the reader's connected provider keys") result: PipelineResult = await _index_repo( - repo_url, provider=ctx["llm"], settings=ctx["settings"] + repo_url, provider=provider, settings=ctx["settings"] ) return { "status": result.status, diff --git a/apps/api/src/repopilot_api/models.py b/apps/api/src/repopilot_api/models.py index ebd4b42..11f2d2f 100644 --- a/apps/api/src/repopilot_api/models.py +++ b/apps/api/src/repopilot_api/models.py @@ -59,7 +59,10 @@ class IntentDraftRequest(BaseModel): class ProviderCredentialsRequest(BaseModel): groq_api_key: SecretStr - huggingface_api_key: SecretStr | None = None + # Both keys are required. Groq answers; Hugging Face is what indexing and + # the third failover tier run on, and a deployment that indexes on the + # platform's own key cannot bound its own cost. + huggingface_api_key: SecretStr class AccountUsageResponse(BaseModel): diff --git a/apps/api/src/repopilot_api/services.py b/apps/api/src/repopilot_api/services.py index 9ea821f..9527057 100644 --- a/apps/api/src/repopilot_api/services.py +++ b/apps/api/src/repopilot_api/services.py @@ -41,7 +41,7 @@ TourFirstImpressionEvent, ) from repopilot_api.tours import InMemoryTourService, PostgresTourService, TourService -from repopilot_core.llm.provider import LLMProvider, ProviderCredentialsError +from repopilot_core.llm.provider import LLMProvider, ProviderCredentialsError, ProviderError from repopilot_core.settings import Settings, get_settings from repopilot_ingestion.clone import parse_github_url from repopilot_ingestion.db import INDEX_RECIPE_VERSION @@ -91,7 +91,11 @@ class RepoRecord: class RepoService(Protocol): async def enqueue( - self, repo_url: str, *, provider: LLMProvider | None = None + self, + repo_url: str, + *, + provider: LLMProvider | None = None, + session_id: str | None = None, ) -> RepoRecord: ... async def get(self, repo_id: str) -> RepoRecord: ... @@ -174,7 +178,21 @@ class LiveRepoService: tasks: dict[str, asyncio.Task[None]] = field(default_factory=dict) redis: ArqRedis | None = None - async def enqueue(self, repo_url: str, *, provider: LLMProvider | None = None) -> RepoRecord: + async def enqueue( + self, + repo_url: str, + *, + provider: LLMProvider | None = None, + session_id: str | None = None, + ) -> RepoRecord: + """Index ``repo_url`` on ``provider`` — the reader's own keys. + + ``session_id`` is passed only when the reader's keys are account-bound, + because that is the one case the background worker can rebuild them + from: it resolves them out of the encrypted store itself, so no API key + is ever written to the job queue. Without it (an anonymous session, + whose keys live in this process's memory) indexing runs here instead. + """ slug = repo_slug(repo_url) existing = await self._load_record_from_db(slug, repo_url=repo_url) if existing is not None and existing.status in {"ready", "stale"}: @@ -186,15 +204,17 @@ async def enqueue(self, repo_url: str, *, provider: LLMProvider | None = None) - record = RepoRecord(repo_id=slug, repo_url=repo_url, status="queued", progress=5) self.records[slug] = record if slug not in self.tasks or self.tasks[slug].done(): - if self.runtime.settings.repopilot_env == "production" and provider is None: - self.tasks[slug] = asyncio.create_task(self._enqueue_worker(slug, repo_url)) + if self.runtime.settings.repopilot_env == "production" and session_id is not None: + self.tasks[slug] = asyncio.create_task( + self._enqueue_worker(slug, repo_url, session_id) + ) else: self.tasks[slug] = asyncio.create_task( self._index(slug, repo_url, provider=provider) ) return record - async def _enqueue_worker(self, slug: str, repo_url: str) -> None: + async def _enqueue_worker(self, slug: str, repo_url: str, session_id: str) -> None: record = self.records[slug] record.status = "indexing" record.progress = 15 @@ -205,7 +225,9 @@ async def _enqueue_worker(self, slug: str, repo_url: str) -> None: self.redis = await create_pool( RedisSettings.from_dsn(self.runtime.settings.redis_url) ) - job = await self.redis.enqueue_job("index_repo", repo_url) + # The session id, never the keys: the worker rehydrates them from + # the encrypted credential store, so nothing secret enters Redis. + job = await self.redis.enqueue_job("index_repo", repo_url, session_id) if job is None: raise RuntimeError("repository indexing job could not be enqueued") await self._apply_worker_result(record, job) @@ -534,7 +556,7 @@ async def attempt() -> QAResult: engine=engine, snapshot_repo_id=snapshot_repo_id, question=question, - fallback_reason=str(exc), + fallback_reason=_reader_facing_reason(exc), ) finally: await engine.dispose() @@ -844,6 +866,21 @@ def decode_chunk_id(chunk_id: str) -> tuple[str, CodeRef]: return repo_id, ref +def _reader_facing_reason(exc: BaseException) -> str: + """Turn a pipeline failure into something the reader can act on. + + ``str(exc)`` went straight into ``retrieval_path`` and the web app renders + it verbatim ("Model call failed: …"), so a free-tier quota window showed up + as ``HTTPStatusError("413 Payload Too Large")`` — which reads as "my + question was too big" and sends the reader off shortening their question, + the one thing that cannot help. Only the shape of the failure is useful + here; the detail belongs in the log line above the call. + """ + if isinstance(exc, ProviderError) and "RateLimitError" in str(exc): + return "the model provider is rate-limited right now — ask again in a minute" + return "the model call failed; the answer below is keyword matching only" + + async def answer_deterministically( *, engine: AsyncEngine, diff --git a/apps/api/tests/test_api_contract.py b/apps/api/tests/test_api_contract.py index 9c3491a..9db23cd 100644 --- a/apps/api/tests/test_api_contract.py +++ b/apps/api/tests/test_api_contract.py @@ -66,9 +66,13 @@ def __init__(self) -> None: ), } self.enqueued: list[str] = [] + self.indexed_on_reader_keys = False - async def enqueue(self, repo_url: str, *, provider: Any = None) -> RepoRecord: - del provider + async def enqueue( + self, repo_url: str, *, provider: Any = None, session_id: str | None = None + ) -> RepoRecord: + self.indexed_on_reader_keys = provider is not None + del session_id self.enqueued.append(repo_url) record = RepoRecord(repo_id="repo-new", repo_url=repo_url, status="queued") self.records[record.repo_id] = record @@ -190,8 +194,21 @@ async def api_client(api_app: FastAPI) -> AsyncIterator[AsyncClient]: yield client +async def _connect_keys(api_client: AsyncClient) -> None: + connected = await api_client.post( + "/account/provider", + json={ + "groq_api_key": "gsk_test_key_long_enough", + "huggingface_api_key": "hf_test_key", + }, + ) + assert connected.status_code == 200 + + @pytest.mark.asyncio async def test_post_repos_enqueues_indexing(api_client: AsyncClient) -> None: + await _connect_keys(api_client) + response = await api_client.post( "/repos", json={"repo_url": "https://github.com/pallets/flask"}, @@ -201,6 +218,40 @@ async def test_post_repos_enqueues_indexing(api_client: AsyncClient) -> None: assert response.json() == {"repo_id": "repo-new", "status": "queued"} +@pytest.mark.asyncio +async def test_post_repos_indexes_on_the_readers_own_keys( + api_client: AsyncClient, api_app: FastAPI +) -> None: + """Indexing is the expensive half — it never runs on the platform's quota.""" + await _connect_keys(api_client) + + await api_client.post("/repos", json={"repo_url": "https://github.com/pallets/flask"}) + + repos = cast(Any, api_app.state.services.repos) + assert repos.indexed_on_reader_keys is True + + +@pytest.mark.asyncio +async def test_post_repos_without_keys_is_refused(api_client: AsyncClient) -> None: + response = await api_client.post( + "/repos", + json={"repo_url": "https://github.com/pallets/flask"}, + ) + + assert response.status_code == 402 + assert response.json()["detail"]["code"] == "PROVIDER_KEY_REQUIRED" + + +@pytest.mark.asyncio +async def test_connect_provider_requires_a_hugging_face_token(api_client: AsyncClient) -> None: + response = await api_client.post( + "/account/provider", + json={"groq_api_key": "gsk_test_key_long_enough"}, + ) + + assert response.status_code == 422 + + @pytest.mark.asyncio async def test_get_repo_status_returns_expected_shape(api_client: AsyncClient) -> None: response = await api_client.get("/repos/repo-123/status") diff --git a/apps/web/src/components/repopilot-app.tsx b/apps/web/src/components/repopilot-app.tsx index 30ce0e3..48bc13d 100644 --- a/apps/web/src/components/repopilot-app.tsx +++ b/apps/web/src/components/repopilot-app.tsx @@ -150,7 +150,7 @@ function ProviderDialog({
Keys go directly to the API and stay with your account, encrypted at rest, so they @@ -175,7 +175,11 @@ function ProviderDialog({