"""Per-user API keys - each user can supply their own key for a provider, used in place of the server's global .env-configured key for their own requests (see get_effective_settings, and its call sites in companies.py, reports.py, tasks/collection.py, tasks/enrichment.py, and collection_service.to_company_context for USPTO patents). Storage is encrypted at rest (app/core/crypto.py). A key is only ever decrypted for the owning user's own list/set calls or to actually place a provider call on their behalf - never exposed to any other user, admin or not (this is a deliberately different visibility model from the admin-only, localhost-only *server* key box in app/api/v1/system.py). """ from __future__ import annotations import uuid from dataclasses import dataclass from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings from app.core.crypto import decrypt_secret, encrypt_secret from app.models.enums import ApiKeyProvider, SecurityEventType from app.repositories.user_api_key_repository import UserApiKeyRepository from app.repositories.user_security_event_repository import UserSecurityEventRepository @dataclass(frozen=True) class ProviderMeta: label: str settings_field: str credits_note: str free: bool = False requires_government_id: bool = False PROVIDER_META: dict[ApiKeyProvider, ProviderMeta] = { ApiKeyProvider.ANTHROPIC: ProviderMeta( label="Anthropic", settings_field="anthropic_api_key", credits_note="Anthropic doesn't expose a credit/usage-balance API.", ), ApiKeyProvider.BRAVE_SEARCH: ProviderMeta( label="Brave Search", settings_field="brave_search_api_key", credits_note=( "Brave Search API has no metered balance endpoint (plan-based, not prepaid credits)." ), ), ApiKeyProvider.NINJAPEAR: ProviderMeta( label="NinjaPear", settings_field="ninjapear_api_key", credits_note="Credit balance shown in System configuration below.", ), ApiKeyProvider.USPTO: ProviderMeta( label="USPTO", settings_field="uspto_api_key", credits_note="Free - USPTO Open Data Portal has no usage limit or credit cost.", free=True, requires_government_id=True, ), } async def list_status( db: AsyncSession, user_id: uuid.UUID, settings: Settings ) -> list[dict[str, Any]]: """Never fetches a live NinjaPear credit balance itself - the frontend sources that number from /system/status's own already-fetched ninjapear_credit_balance (see the Settings page's System configuration box) instead of this endpoint making a second, redundant live call.""" repo = UserApiKeyRepository(db) stored = {row.provider: row for row in await repo.list_for_user(user_id)} results: list[dict[str, Any]] = [] for provider, meta in PROVIDER_META.items(): row = stored.get(provider) value = decrypt_secret(row.encrypted_key, settings) if row is not None else None results.append( { "provider": provider.value, "label": meta.label, "configured": row is not None, "value": value, "credits": None, "credits_note": meta.credits_note, "free": meta.free, "requires_government_id": meta.requires_government_id, } ) return results async def set_key( db: AsyncSession, user_id: uuid.UUID, provider: ApiKeyProvider, plaintext_key: str, settings: Settings, *, client_ip: str, ) -> None: """An empty/blank key clears the user's override, falling back to the server's global key for that provider again. Every update - including a clear - is logged to this same user's own Account activity.""" repo = UserApiKeyRepository(db) stripped = plaintext_key.strip() if not stripped: await repo.delete(user_id, provider) else: await repo.upsert(user_id, provider, encrypt_secret(stripped, settings)) await UserSecurityEventRepository(db).create( user_id=user_id, event_type=SecurityEventType.API_KEY_UPDATED, ip_address=client_ip ) await db.commit() async def get_effective_settings( db: AsyncSession, user_id: uuid.UUID, settings: Settings ) -> Settings: """A copy of the global settings with any of this user's own stored keys substituted in for the matching field - providers they haven't set their own key for keep using the server's global default.""" rows = await UserApiKeyRepository(db).list_for_user(user_id) if not rows: return settings overrides = { PROVIDER_META[row.provider].settings_field: decrypt_secret(row.encrypted_key, settings) for row in rows } return settings.model_copy(update=overrides)