Files
periscope/backend/config.py
T
micheleandCursor 61f85f519b Default to DeepSeek V4.1 Flash, show API cost in USD, and re-analyze after replacing BOM and netlist.
V4.1 is natively multimodal so every stage uses deepseek-flash; pricing and the UI now surface dollars instead of empty credits. KiCad netlists and neighborhood fingerprints land in the same cut so a second run can keep the project and skip unchanged ICs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 20:55:35 +02:00

307 lines
12 KiB
Python

"""Backend configuration via environment variables."""
import importlib.util
import json
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings
# Resolve paths relative to the project root (one level up from backend/)
_BACKEND_DIR = Path(__file__).resolve().parent
_PROJECT_ROOT = _BACKEND_DIR.parent
# Load skills manifest once at import time
_MANIFEST_PATH = _BACKEND_DIR / "skills_manifest.json"
_SKILLS_MANIFEST: dict = (
json.loads(_MANIFEST_PATH.read_text()) if _MANIFEST_PATH.exists() else {}
)
class Settings(BaseSettings):
# DeepSeek (default provider — OpenAI-compatible Chat Completions)
deepseek_api_key: str = ""
deepseek_base_url: str = "https://api.deepseek.com"
deepseek_model: str = "deepseek-flash"
deepseek_vision_model: str = "deepseek-flash"
# "enabled" (default) or "disabled". DeepSeek V4 thinks by default;
# disable to cut cost on simple mapping calls.
deepseek_thinking: str = "enabled"
# Official values: low | high | max. Review sessions with
# max_tokens >= 16000 still bump to "high" in the provider.
deepseek_reasoning_effort: str = "high"
# PDF ingest: DeepSeek does not accept native PDFs. Text is always
# extracted; page images are attached only when the stage model is a
# vision model (see model_*_deepseek defaults below).
deepseek_pdf_max_chars: int = 500_000
deepseek_pdf_image_pages: int = 32
# Per-stage DeepSeek model overrides (fall back to deepseek_model)
model_pintable_deepseek: str = "deepseek-flash"
model_pattern_deepseek: str = "deepseek-flash"
model_specs_deepseek: str = "deepseek-flash"
model_validation_deepseek: str = "deepseek-flash"
model_auto_resolve_deepseek: str = "deepseek-flash"
model_normalize_deepseek: str = "deepseek-flash"
# Anthropic (optional fallback)
anthropic_api_key: str = ""
anthropic_model: str = "claude-sonnet-4-6"
# Per-stage model overrides (fall back to anthropic_model if empty)
model_pintable: str = ""
model_pattern: str = ""
model_specs: str = ""
model_validation: str = "claude-sonnet-4-6"
model_auto_resolve: str = "claude-haiku-4-5-20251001"
model_normalize: str = "claude-sonnet-4-6"
# Gemini (set GEMINI_API_KEY to enable)
gemini_api_key: str = ""
gemini_model: str = "gemini-3.1-pro-preview"
# Per-stage Gemini model overrides (fall back to gemini_model if empty)
model_validation_gemini: str = ""
model_pintable_gemini: str = ""
model_pattern_gemini: str = ""
model_specs_gemini: str = ""
model_auto_resolve_gemini: str = ""
model_normalize_gemini: str = ""
# Provider routing — provider_default is the global default; per-stage
# overrides win when non-empty. Valid values: deepseek | anthropic | gemini.
provider_default: str = "deepseek"
provider_pintable: str = ""
provider_pattern: str = ""
provider_specs: str = ""
provider_validation: str = ""
provider_auto_resolve: str = ""
provider_normalize: str = ""
# Per-stage fallback provider/model — used if the primary stage call
# raises (e.g. DeepSeek 503). Leave empty to disable fallback
# for that stage. If fallback_provider_<stage> is set but
# fallback_model_<stage> is empty, the fallback uses that provider's
# default model (deepseek_model, anthropic_model, or gemini_model).
fallback_provider_pintable: str = ""
fallback_provider_pattern: str = ""
fallback_provider_specs: str = ""
fallback_provider_validation: str = ""
fallback_provider_auto_resolve: str = ""
fallback_provider_normalize: str = ""
fallback_model_pintable: str = ""
fallback_model_pattern: str = ""
fallback_model_specs: str = ""
fallback_model_validation: str = ""
fallback_model_auto_resolve: str = ""
fallback_model_normalize: str = ""
# Max parallel IC agents — the single knob controlling concurrency for
# BOTH the IC pintable extraction stage and the direct datasheet review
# stage. Change this one number (or the IC_CONCURRENCY env var) to scale
# how many ICs are processed in parallel.
ic_concurrency: int = 6
# Per-IC normalize pass — dedup findings sharing a root cause and
# re-grade severity against a fixed rubric. Runs after submit_review.
normalize_findings_enabled: bool = True
# Cross-IC dedup pass — collapse one physical interface defect reported
# from both ICs (e.g. a 5V-into-3V3 net flagged once per endpoint) into a
# single finding. Runs once after all per-IC reviews complete.
cross_ic_dedup_enabled: bool = True
# Paths (relative to project root, used by LocalStorageBackend)
data_dir: Path = _PROJECT_ROOT / "data"
taxonomy_dir: Path = _PROJECT_ROOT / "taxonomy"
skills_dir: Path = _PROJECT_ROOT / "skills"
# GCS (if set, use GCSStorageBackend; otherwise LocalStorageBackend)
gcs_bucket: str = ""
# Clerk authentication
clerk_secret_key: str = ""
clerk_publishable_key: str = ""
clerk_jwks_url: str = ""
# DigiKey API (optional — enables auto-fetch datasheets)
digikey_client_id: str = ""
digikey_client_secret: str = ""
digikey_environment: str = "production"
digikey_locale_site: str = "US"
digikey_locale_language: str = "en"
digikey_locale_currency: str = "USD"
# Mouser Search API (optional — fourth datasheet source)
mouser_api_key: str = ""
# Purple Parts API (optional — converts LCSC codes to MPNs before DigiKey)
purple_parts_url: str = ""
purple_parts_api_key: str = ""
# Email notifications (Gmail API via service account)
email_sender: str = ""
email_frontend_url: str = ""
email_admin_notify: str = "" # fixed recipient for pipeline-started alerts
contact_recipient: str = "" # where /api/contact submissions are delivered
# Stripe billing (pay-as-you-go only — no subscription prices needed)
stripe_secret_key: str = ""
stripe_webhook_secret: str = ""
# Open-core: master switch for the credits/Stripe billing system.
# True = credit gating + charges + billing/credits routers.
# False = OSS/self-host mode: pipelines run free, billing routes unmounted.
# Defaults to whether the private billing modules exist in this checkout
# (present in the cloud/gateway repo, absent in the open-source core), so
# a bare core checkout runs free with no configuration. An explicit
# BILLING_ENABLED env var always wins.
billing_enabled: bool = Field(
default_factory=lambda: importlib.util.find_spec(
"backend.services.stripe_billing"
)
is not None
)
# Onboarding survey (Google Sheet)
survey_sheet_id: str = ""
# CORS
cors_origins: list[str] = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:18742",
"http://127.0.0.1:18742",
]
# Cloud Run Job worker (pipeline runner)
pipeline_worker_job_name: str = "pinscopex-pipeline-worker"
pipeline_worker_region: str = "us-central1"
pipeline_worker_project: str = "" # GCP project id; defaults to GOOGLE_CLOUD_PROJECT or metadata
pipeline_worker_timeout_seconds: int = 3600
# Sweeper: a "running" project is considered stale if its last update
# timestamp is older than this and the worker execution is in a
# terminal Cloud Run state (or the executor isn't reachable).
pipeline_sweeper_stale_seconds: int = 60
model_config = {
"env_file": str(_BACKEND_DIR / ".env"),
"env_file_encoding": "utf-8",
"extra": "ignore",
}
@property
def use_stripe(self) -> bool:
return bool(self.stripe_secret_key)
@property
def use_digikey(self) -> bool:
return bool(self.digikey_client_id and self.digikey_client_secret)
@property
def use_mouser(self) -> bool:
return bool(self.mouser_api_key)
@property
def use_purple_parts(self) -> bool:
return bool(self.purple_parts_url and self.purple_parts_api_key)
@property
def use_gcs(self) -> bool:
return bool(self.gcs_bucket)
@property
def use_auth(self) -> bool:
return bool(self.clerk_secret_key and self.clerk_jwks_url)
@property
def use_email(self) -> bool:
return bool(self.email_sender and self.email_frontend_url)
def provider_for_stage(self, stage: str) -> str:
"""Return the LLM provider name for a pipeline stage."""
override = getattr(self, f"provider_{stage}", "")
return override or self.provider_default
def model_for_stage(self, stage: str) -> str:
"""Return the model for a pipeline stage, provider-aware.
For DeepSeek: falls back to model_<stage>_deepseek, then deepseek_model.
For Gemini: falls back to model_<stage>_gemini, then gemini_model.
For Anthropic: falls back to model_<stage>, then anthropic_model.
"""
provider = self.provider_for_stage(stage)
if provider == "gemini":
override = getattr(self, f"model_{stage}_gemini", "")
return override or self.gemini_model
if provider == "deepseek":
override = getattr(self, f"model_{stage}_deepseek", "")
return override or self.deepseek_model
override = getattr(self, f"model_{stage}", "")
return override or self.anthropic_model
def fallback_for_stage(self, stage: str) -> tuple[str, str] | None:
"""Return (provider, model) for the stage's fallback, or None if no
fallback is configured. Used by call_with_fallback() to retry once
when the primary provider raises.
"""
fb_provider = getattr(self, f"fallback_provider_{stage}", "")
if not fb_provider:
return None
fb_model = getattr(self, f"fallback_model_{stage}", "")
if not fb_model:
if fb_provider == "gemini":
fb_model = self.gemini_model
elif fb_provider == "deepseek":
fb_model = self.deepseek_model
else:
fb_model = self.anthropic_model
return (fb_provider, fb_model)
def default_model_for_provider(self, provider: str) -> str:
if provider == "gemini":
return self.gemini_model
if provider == "deepseek":
return self.deepseek_model
return self.anthropic_model
def has_llm_credentials(self) -> bool:
"""True if the configured default provider has an API key."""
name = self.provider_default
if name == "deepseek":
return bool(self.deepseek_api_key)
if name == "gemini":
return bool(self.gemini_api_key)
if name == "anthropic":
return bool(self.anthropic_api_key)
return bool(
self.deepseek_api_key or self.anthropic_api_key or self.gemini_api_key
)
def get_skill_or_none(self, name: str) -> tuple[str | None, str | None]:
"""Return (skill_id, version) or (None, None) if the Anthropic
Console skill is not in the manifest. DeepSeek/Gemini extraction
inlines SKILL.md locally and does not need a skill_id."""
entry = _SKILLS_MANIFEST.get(name)
if not entry:
return None, None
return entry.get("skill_id"), entry.get("latest_version")
def get_default_model_version(self) -> str:
"""Return the default model_version for new extractions from skills_manifest.json."""
return _SKILLS_MANIFEST.get("default_model_version", "1.0.0")
def get_skill(self, name: str) -> tuple[str, str]:
"""Return (skill_id, version) from skills_manifest.json or raise."""
entry = _SKILLS_MANIFEST.get(name)
if not entry:
raise RuntimeError(
f"Skill '{name}' not found in {_MANIFEST_PATH}. "
f"Run scripts/upload_skills.py to create skills."
)
return entry["skill_id"], entry["latest_version"]
settings = Settings()