Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 64 additions & 28 deletions apps/api/src/repopilot_api/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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: ...
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -203,43 +210,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:
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.
account = await self._account_key(session_id)
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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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())
Expand Down
30 changes: 18 additions & 12 deletions apps/api/src/repopilot_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
},
)
Expand All @@ -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
Expand All @@ -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
Expand Down
29 changes: 21 additions & 8 deletions apps/api/src/repopilot_api/jobs/index_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/repopilot_api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading