Files
periscope/backend/services/llm/factory.py
T
Cursor Agent 48246f31bd Adapt Pinscope to DeepSeek, auto datasheets, and a shared library.
Based on manvalan/pinscope main. Default LLM is DeepSeek with local
skills and PDF ingest. Datasheets are fetched from LCSC/TI, stored in
the component library, and review extracts abs-max with a deeper
checklist. Adds scripts/update-pinscope.sh for the production host.
2026-08-27 23:06:23 +00:00

78 lines
2.6 KiB
Python

"""Provider factory + per-stage routing."""
from __future__ import annotations
import asyncio
import logging
from functools import lru_cache
from typing import Awaitable, Callable, TypeVar
from backend.config import settings
from backend.services.llm.base import LLMProvider
log = logging.getLogger(__name__)
T = TypeVar("T")
@lru_cache(maxsize=8)
def get_provider_by_name(name: str) -> LLMProvider:
"""Return a singleton provider instance for ``name`` ("deepseek" |
"anthropic" | "gemini"). Used by :func:`get_provider` and
:func:`call_with_fallback`."""
if name == "deepseek":
from backend.services.llm.deepseek_provider import DeepSeekProvider
return DeepSeekProvider()
if name == "anthropic":
from backend.services.llm.anthropic_provider import AnthropicProvider
return AnthropicProvider()
if name == "gemini":
from backend.services.llm.gemini_provider import GeminiProvider
return GeminiProvider()
raise ValueError(f"Unknown LLM provider: {name!r}")
# Backwards-compatible alias
_get_provider_by_name = get_provider_by_name
def get_provider(stage: str) -> LLMProvider:
"""Return the provider configured for ``stage``.
Falls back to ``settings.provider_default`` if no per-stage override.
Providers are cached per-name, so repeated calls return the same
instance (and share the underlying SDK client)."""
name = settings.provider_for_stage(stage)
return get_provider_by_name(name)
async def call_with_fallback(
stage: str,
body: Callable[[LLMProvider, str], Awaitable[T]],
) -> T:
"""Run ``body(provider, model)`` for ``stage``; on any exception,
retry once with the fallback provider/model if one is configured via
``FALLBACK_PROVIDER_<STAGE>`` / ``FALLBACK_MODEL_<STAGE>``.
The fallback runs ``body`` from scratch — any tokens spent in the
primary attempt are lost (and not logged). ``asyncio.CancelledError``
is always re-raised so cancellation still works.
"""
primary_provider = get_provider(stage)
primary_model = settings.model_for_stage(stage)
try:
return await body(primary_provider, primary_model)
except asyncio.CancelledError:
raise
except Exception as exc:
fb = settings.fallback_for_stage(stage)
if fb is None:
raise
log.warning(
"[%s] primary %s/%s failed (%s) — falling back to %s/%s",
stage, primary_provider.name, primary_model,
exc, fb[0], fb[1],
)
fallback_provider = get_provider_by_name(fb[0])
return await body(fallback_provider, fb[1])