Skip to content

Commit 135a5bf

Browse files
committed
refactor(ai): single provider registry as the backend source of truth
Provider metadata (env vars, modalities, connection-test models, OpenAI-compatible discovery URLs, display names, docs links) is now defined once in open_notebook/ai/provider_registry.py. The existing surfaces are derived from it, keeping every import and call-site shape unchanged: - api/credentials_service.py: PROVIDER_ENV_CONFIG, PROVIDER_MODALITIES and the discovery url_map are built from the registry - open_notebook/ai/connection_tester.py: TEST_MODELS derived - open_notebook/ai/model_discovery.py: OPENAI_COMPAT_PROVIDERS built from registry entries with a discovery URL (quirk hooks stay local) The SupportedProvider Literal (typing, can't be built at runtime) and the frontend provider tables remain manual copies; the cross-check tests now assert registry keys == Literal == frontend list, plus registry internal consistency and discovery-table coverage. New GET /api/providers endpoint exposes the registry (name, display name, modalities, docs_url, env-configured status) so clients can stop hardcoding provider lists (frontend adoption is a follow-up). Docs updated: open_notebook/AGENTS.md and docs/7-DEVELOPMENT/credentials.md now describe the registry instead of the four-place sync rule.
1 parent dbffa4c commit 135a5bf

11 files changed

Lines changed: 382 additions & 120 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Changed
11+
- Backend provider metadata now lives in a single registry (`open_notebook/ai/provider_registry.py`): env var config, modalities, connection-test models, OpenAI-compatible discovery URLs and docs links are defined once per provider, and `PROVIDER_ENV_CONFIG`, `PROVIDER_MODALITIES`, `TEST_MODELS` and `OPENAI_COMPAT_PROVIDERS` are derived from it. Adding a provider drops from ~6 hand-synced dicts to the registry plus two manual copies (the `SupportedProvider` Literal and the frontend provider table), both enforced by tests
1112
- Pruned unused langchain packages: removed `langchain-community` and `langchain-deepseek` from the dependencies (nothing imports them — DeepSeek and xAI route through esperanto's OpenAI-compatible path, which uses `langchain-openai`). The remaining `langchain-*` provider packages are documented as runtime requirements of esperanto's dynamic `to_langchain()` and the whole langchain/langgraph family now carries explicit upper bounds; `langchain-core` and `langchain-text-splitters` (both directly imported but previously only transitive) are now declared explicitly
1213
- The two Docker images (regular and single-container) are now built from a single multi-stage `Dockerfile` with shared stages — regular is the default (`runtime`) target, single-container is `--target single` — so deploy fixes (tiktoken pre-cache, env defaults, npm retry logic) no longer have to be applied twice. `Dockerfile.single` and `supervisord.single.conf` were removed; the single image appends a small `supervisord.surrealdb.conf` to the shared `supervisord.conf` at build time. Published image names and tags are unchanged
1314

1415
### Added
16+
- New `GET /api/providers` endpoint returning provider metadata from the registry (name, display name, modalities, docs URL, whether it is configured via environment variables), so clients can enumerate supported providers instead of hardcoding them
1517
- Release confidence process, documented and executable: `.github/RELEASE_PROCESS.md` now covers the risk-based test matrix (buckets A/B/C), the Docker image gate, the fix-loop re-test policy and the communication/credits/retro structure, backed by a new decision record (ADR-005) and versioned tooling under `scripts/release-test/``make release-test TAG= OLD_TAG=` runs fresh-install + upgrade scenarios against real images, and `make release-stack TAG= [DUMP=]` boots a browsable, isolated release-candidate stack (optionally with a copy of dev data) for manual verification
1618
- CI now gates every PR on `ruff check` (backend lint), `npm run lint` (frontend ESLint) and `npm run build` (frontend production build), in addition to the existing test suites
1719

api/credentials_service.py

Lines changed: 9 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
classify_model_type,
2121
fetch_anthropic_model_ids,
2222
)
23+
from open_notebook.ai.provider_registry import PROVIDERS
2324
from open_notebook.domain.credential import Credential
2425
from open_notebook.utils.encryption import get_secret_from_env
2526
from open_notebook.utils.url_validation import validate_url
@@ -28,61 +29,17 @@
2829
# Constants
2930
# =============================================================================
3031

31-
# Provider environment variable configuration.
32+
# Provider environment variable configuration, derived from the provider
33+
# registry (open_notebook/ai/provider_registry.py — the source of truth).
3234
# - "required": ALL listed env vars must be set for the provider to be considered configured.
3335
# - "required_any": at least ONE of the listed env vars must be set.
3436
# - "optional": additional env vars used during migration but not required.
3537
PROVIDER_ENV_CONFIG: Dict[str, dict] = {
36-
"openai": {"required": ["OPENAI_API_KEY"]},
37-
"anthropic": {"required": ["ANTHROPIC_API_KEY"]},
38-
"google": {"required_any": ["GOOGLE_API_KEY", "GEMINI_API_KEY"]},
39-
"groq": {"required": ["GROQ_API_KEY"]},
40-
"mistral": {"required": ["MISTRAL_API_KEY"]},
41-
"deepseek": {"required": ["DEEPSEEK_API_KEY"]},
42-
"xai": {"required": ["XAI_API_KEY"]},
43-
"openrouter": {"required": ["OPENROUTER_API_KEY"]},
44-
"voyage": {"required": ["VOYAGE_API_KEY"]},
45-
"elevenlabs": {"required": ["ELEVENLABS_API_KEY"]},
46-
"deepgram": {"required": ["DEEPGRAM_API_KEY"]},
47-
"ollama": {"required": ["OLLAMA_API_BASE"]},
48-
"vertex": {
49-
"required": ["VERTEX_PROJECT", "VERTEX_LOCATION"],
50-
"optional": ["GOOGLE_APPLICATION_CREDENTIALS"],
51-
},
52-
"azure": {
53-
"required": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_VERSION"],
54-
"optional": [
55-
"AZURE_OPENAI_ENDPOINT_LLM",
56-
"AZURE_OPENAI_ENDPOINT_EMBEDDING",
57-
"AZURE_OPENAI_ENDPOINT_STT",
58-
"AZURE_OPENAI_ENDPOINT_TTS",
59-
],
60-
},
61-
"openai_compatible": {
62-
"required_any": ["OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY"],
63-
},
64-
"dashscope": {"required": ["DASHSCOPE_API_KEY"]},
65-
"minimax": {"required": ["MINIMAX_API_KEY"]},
38+
name: spec.env_config() for name, spec in PROVIDERS.items()
6639
}
6740

6841
PROVIDER_MODALITIES: Dict[str, List[str]] = {
69-
"openai": ["language", "embedding", "speech_to_text", "text_to_speech"],
70-
"anthropic": ["language"],
71-
"google": ["language", "embedding", "speech_to_text", "text_to_speech"],
72-
"groq": ["language", "speech_to_text"],
73-
"mistral": ["language", "embedding", "speech_to_text", "text_to_speech"],
74-
"deepseek": ["language"],
75-
"xai": ["language", "text_to_speech"],
76-
"openrouter": ["language", "embedding"],
77-
"voyage": ["embedding"],
78-
"elevenlabs": ["text_to_speech", "speech_to_text"],
79-
"deepgram": ["text_to_speech"],
80-
"ollama": ["language", "embedding"],
81-
"vertex": ["language", "embedding", "text_to_speech"],
82-
"azure": ["language", "embedding", "speech_to_text", "text_to_speech"],
83-
"openai_compatible": ["language", "embedding", "speech_to_text", "text_to_speech"],
84-
"dashscope": ["language"],
85-
"minimax": ["language"],
42+
name: list(spec.modalities) for name, spec in PROVIDERS.items()
8643
}
8744

8845

@@ -426,16 +383,11 @@ def models_endpoint(url: str) -> str:
426383
model_names = list(ANTHROPIC_FALLBACK_MODELS)
427384
return [{"name": m, "provider": "anthropic"} for m in model_names]
428385

429-
# API-based discovery URLs (OpenAI-style /models endpoints)
386+
# API-based discovery URLs (OpenAI-style /models endpoints), from the registry
430387
url_map = {
431-
"openai": "https://api.openai.com/v1/models",
432-
"groq": "https://api.groq.com/openai/v1/models",
433-
"mistral": "https://api.mistral.ai/v1/models",
434-
"deepseek": "https://api.deepseek.com/models",
435-
"xai": "https://api.x.ai/v1/models",
436-
"openrouter": "https://openrouter.ai/api/v1/models",
437-
"dashscope": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
438-
"minimax": "https://api.minimax.io/v1/models",
388+
name: spec.openai_compat_discovery_url
389+
for name, spec in PROVIDERS.items()
390+
if spec.openai_compat_discovery_url
439391
}
440392

441393
if provider == "ollama":

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
notebooks,
3131
notes,
3232
podcasts,
33+
providers,
3334
search,
3435
settings,
3536
source_chat,
@@ -391,6 +392,7 @@ async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
391392
app.include_router(chat.router, prefix="/api", tags=["chat"])
392393
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
393394
app.include_router(credentials.router, prefix="/api", tags=["credentials"])
395+
app.include_router(providers.router, prefix="/api", tags=["providers"])
394396
app.include_router(languages.router, prefix="/api", tags=["languages"])
395397

396398

api/models.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -571,10 +571,11 @@ class MigrationResult(BaseModel):
571571
# Notebook delete cascade models
572572
# Credential models
573573

574-
# Kept in sync with the frontend's ALL_PROVIDERS
575-
# (frontend/src/app/(dashboard)/settings/api-keys/page.tsx), TEST_MODELS
576-
# (open_notebook/ai/connection_tester.py), and PROVIDER_ENV_CONFIG
577-
# (api/credentials_service.py) - all three independently agree on this set.
574+
# Kept in sync with the provider registry
575+
# (open_notebook/ai/provider_registry.py PROVIDERS — the backend source of
576+
# truth) and the frontend's ALL_PROVIDERS (frontend/src/lib/providers.tsx).
577+
# A Literal can't be built at runtime, so this is one of the two remaining
578+
# manual copies; tests/test_credential_provider_validation.py enforces the sync.
578579
SupportedProvider = Literal[
579580
"openai",
580581
"anthropic",
@@ -596,6 +597,22 @@ class MigrationResult(BaseModel):
596597
]
597598

598599

600+
class ProviderInfoResponse(BaseModel):
601+
"""Provider metadata from the provider registry."""
602+
603+
name: str = Field(..., description="Provider identifier (e.g. openai)")
604+
display_name: str = Field(..., description="Human-friendly provider name")
605+
modalities: List[str] = Field(
606+
..., description="Default modalities supported by the provider"
607+
)
608+
docs_url: Optional[str] = Field(
609+
None, description="Where to get an API key / set the provider up"
610+
)
611+
env_configured: bool = Field(
612+
..., description="Whether the provider is configured via environment variables"
613+
)
614+
615+
599616
class CreateCredentialRequest(BaseModel):
600617
"""Request to create a new credential."""
601618

api/routers/providers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""
2+
Providers Router
3+
4+
Exposes the provider registry (open_notebook/ai/provider_registry.py) so
5+
clients can enumerate supported providers and their metadata instead of
6+
keeping their own copies.
7+
8+
Endpoints:
9+
- GET /providers - List all supported providers with metadata
10+
"""
11+
12+
from typing import List
13+
14+
from fastapi import APIRouter
15+
16+
from api.credentials_service import check_env_configured
17+
from api.models import ProviderInfoResponse
18+
from open_notebook.ai.provider_registry import PROVIDERS
19+
20+
router = APIRouter(prefix="/providers", tags=["providers"])
21+
22+
23+
@router.get("", response_model=List[ProviderInfoResponse])
24+
async def list_providers():
25+
"""List all supported AI providers with their registry metadata."""
26+
return [
27+
ProviderInfoResponse(
28+
name=spec.name,
29+
display_name=spec.display_name,
30+
modalities=list(spec.modalities),
31+
docs_url=spec.docs_url,
32+
env_configured=check_env_configured(spec.name),
33+
)
34+
for spec in PROVIDERS.values()
35+
]

docs/7-DEVELOPMENT/credentials.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Settings UI ──► /credentials API ──► Credential record (encrypted, S
3636

3737
CRUD plus lifecycle operations: `POST /credentials/{id}/test` (connection check), `/discover` (list available models), `/register-models` (create Model records from discovery), and two migration endpoints (`/migrate-from-env`, `/migrate-from-provider-config`). Swagger at `/docs` documents the shapes.
3838

39-
**Supported providers (17)** are enforced by the `SupportedProvider` Literal in `api/models.py`, kept in sync with three other locations (frontend `ALL_PROVIDERS`, `connection_tester.TEST_MODELS`, `credentials_service.PROVIDER_ENV_CONFIG`):
39+
**Supported providers (17)** are defined once in the provider registry (`open_notebook/ai/provider_registry.py` `PROVIDERS`) — env vars, modalities, test models, discovery URLs and docs links all live there, and `connection_tester.TEST_MODELS`, `credentials_service.PROVIDER_ENV_CONFIG`/`PROVIDER_MODALITIES` and `model_discovery.OPENAI_COMPAT_PROVIDERS` are derived from it. `GET /api/providers` exposes the registry to clients. Two manual copies remain, enforced by `tests/test_credential_provider_validation.py`: the `SupportedProvider` Literal in `api/models.py` (typing can't be derived at runtime) and the frontend `ALL_PROVIDERS` tables (`frontend/src/lib/providers.tsx`):
4040

4141
- Simple API key: openai, anthropic, google, groq, mistral, deepseek, xai, openrouter, voyage, elevenlabs, deepgram, dashscope, minimax
4242
- URL-based: ollama

open_notebook/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Normative rules for working on the Python backend. Architecture and design ratio
1212
## API layer (`api/`)
1313

1414
- Structure is routes → services → models. Routers stay thin; business logic goes in `*_service.py`.
15-
- `SupportedProvider` must stay in sync across **four** locations: `api/models.py` (Literal), frontend `ALL_PROVIDERS`, `connection_tester.py` `TEST_MODELS`, and `credentials_service.py` `PROVIDER_ENV_CONFIG`. Adding a provider means touching all four.
15+
- Provider metadata (env vars, modalities, test models, discovery URLs, docs links) lives in the registry: `open_notebook/ai/provider_registry.py` `PROVIDERS`. `TEST_MODELS`, `PROVIDER_ENV_CONFIG`, `PROVIDER_MODALITIES` and `OPENAI_COMPAT_PROVIDERS` are derived from it, and `GET /api/providers` exposes it. Adding a provider = add it to the registry, plus **two** manual copies: the `SupportedProvider` Literal in `api/models.py` (typing can't be derived at runtime) and the frontend tables in `frontend/src/lib/providers.tsx` — both enforced by `tests/test_credential_provider_validation.py`.
1616
- NEVER return API key values from any endpoint — metadata only.
1717
- Every user-supplied URL field must go through `validate_url()` (`open_notebook/utils/url_validation.py`, async) for SSRF protection. Private IPs/localhost are intentionally allowed (self-hosted Ollama, LM Studio).
1818
- Errors: raise typed exceptions from `open_notebook.exceptions` — global handlers map them to HTTP status codes (`NotFoundError`→404, `InvalidInputError`→400, `AuthenticationError`→401, `RateLimitError`→429, `ConfigurationError`→422, `NetworkError`/`ExternalServiceError`→502, `OpenNotebookError`→500). Don't raise bare `HTTPException` for domain errors.

open_notebook/ai/connection_tester.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import httpx
1515
from loguru import logger
1616

17+
from open_notebook.ai.provider_registry import PROVIDERS
1718
from open_notebook.utils.url_validation import validate_url
1819

1920

@@ -47,8 +48,9 @@ def _is_vertex_credentials_file_error(exc: Exception) -> bool:
4748
return isinstance(exc, (OSError, json.JSONDecodeError, GoogleAuthError))
4849

4950

50-
# Test models for each provider - uses minimal/cheapest models for testing
51-
# Format: (model_name, model_type)
51+
# Test models for each provider - uses minimal/cheapest models for testing.
52+
# Derived from the provider registry (the source of truth for test models).
53+
# Format: (model_name, model_type); None model = dynamic (first available).
5254
#
5355
# Prefer a provider-maintained floating alias where one exists, so a model
5456
# retirement doesn't silently break the connection test (see #970: Google
@@ -57,25 +59,8 @@ def _is_vertex_credentials_file_error(exc: Exception) -> bool:
5759
# its own. The provider test also no longer treats a model-level failure as
5860
# a connection failure (see `_connection_failure_reason`), so even if an
5961
# alias ever breaks, the test still reports the credentials correctly.
60-
TEST_MODELS = {
61-
"openai": ("gpt-3.5-turbo", "language"),
62-
"anthropic": ("claude-3-haiku-20240307", "language"),
63-
"google": ("gemini-flash-latest", "language"),
64-
"groq": ("llama-3.1-8b-instant", "language"),
65-
"mistral": ("mistral-small-latest", "language"),
66-
"deepseek": ("deepseek-chat", "language"),
67-
"xai": ("grok-beta", "language"),
68-
"openrouter": ("openai/gpt-3.5-turbo", "language"),
69-
"voyage": ("voyage-3-lite", "embedding"),
70-
"elevenlabs": ("eleven_multilingual_v2", "text_to_speech"),
71-
"deepgram": ("aura-2-thalia-en", "text_to_speech"),
72-
"ollama": (None, "language"), # Dynamic - will use first available model
73-
# Complex providers with additional configuration
74-
"vertex": ("gemini-flash-latest", "language"), # Uses Google Vertex AI
75-
"azure": ("gpt-35-turbo", "language"), # Azure OpenAI deployment name
76-
"openai_compatible": (None, "language"), # Dynamic - will use first available model
77-
"dashscope": ("qwen-plus", "language"),
78-
"minimax": ("MiniMax-M2.5", "language"),
62+
TEST_MODELS: dict = {
63+
name: (spec.test_model, spec.test_model_type) for name, spec in PROVIDERS.items()
7964
}
8065

8166

open_notebook/ai/model_discovery.py

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from loguru import logger
1515

1616
from open_notebook.ai.models import Model
17+
from open_notebook.ai.provider_registry import PROVIDERS
1718
from open_notebook.database.repository import repo_query
1819
from open_notebook.domain.credential import Credential
1920

@@ -218,43 +219,28 @@ class ProviderDiscoverySpec:
218219
description: Optional[Callable[[dict], Optional[str]]] = None
219220

220221

222+
# Per-provider quirk hooks that can't live in the (pure data) registry.
223+
_COMPAT_CLASSIFY: Dict[str, Callable[[dict], str]] = {
224+
"mistral": _classify_mistral,
225+
# OpenRouter models are typically language models
226+
"openrouter": lambda model: "language",
227+
}
228+
_COMPAT_DESCRIPTION: Dict[str, Callable[[dict], Optional[str]]] = {
229+
"openrouter": lambda model: model.get("name"),
230+
}
231+
232+
# Built from the provider registry: every provider with an
233+
# `openai_compat_discovery_url` gets a discovery spec. The API key env var
234+
# is the provider's (single) required env var from the registry.
221235
OPENAI_COMPAT_PROVIDERS: Dict[str, ProviderDiscoverySpec] = {
222-
"openai": ProviderDiscoverySpec(
223-
url="https://api.openai.com/v1/models",
224-
env_var="OPENAI_API_KEY",
225-
),
226-
"groq": ProviderDiscoverySpec(
227-
url="https://api.groq.com/openai/v1/models",
228-
env_var="GROQ_API_KEY",
229-
),
230-
"mistral": ProviderDiscoverySpec(
231-
url="https://api.mistral.ai/v1/models",
232-
env_var="MISTRAL_API_KEY",
233-
classify=_classify_mistral,
234-
),
235-
"deepseek": ProviderDiscoverySpec(
236-
url="https://api.deepseek.com/models",
237-
env_var="DEEPSEEK_API_KEY",
238-
),
239-
"xai": ProviderDiscoverySpec(
240-
url="https://api.x.ai/v1/models",
241-
env_var="XAI_API_KEY",
242-
),
243-
"openrouter": ProviderDiscoverySpec(
244-
url="https://openrouter.ai/api/v1/models",
245-
env_var="OPENROUTER_API_KEY",
246-
# OpenRouter models are typically language models
247-
classify=lambda model: "language",
248-
description=lambda model: model.get("name"),
249-
),
250-
"dashscope": ProviderDiscoverySpec(
251-
url="https://dashscope.aliyuncs.com/compatible-mode/v1/models",
252-
env_var="DASHSCOPE_API_KEY",
253-
),
254-
"minimax": ProviderDiscoverySpec(
255-
url="https://api.minimax.io/v1/models",
256-
env_var="MINIMAX_API_KEY",
257-
),
236+
name: ProviderDiscoverySpec(
237+
url=spec.openai_compat_discovery_url,
238+
env_var=spec.required_env[0],
239+
classify=_COMPAT_CLASSIFY.get(name),
240+
description=_COMPAT_DESCRIPTION.get(name),
241+
)
242+
for name, spec in PROVIDERS.items()
243+
if spec.openai_compat_discovery_url
258244
}
259245

260246

0 commit comments

Comments
 (0)