Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

1171 lines
40 KiB
Python

"""Nanobot-style normalized runtime configuration for DeepTutor."""
from __future__ import annotations
from dataclasses import dataclass, field
import json
from typing import Any
from urllib.parse import urlparse
from deeptutor.services.imagegen.config import ImagegenConfig
from deeptutor.services.model_selection import LLMSelection, apply_llm_selection_to_catalog
from deeptutor.services.provider_registry import (
NANOBOT_LLM_PROVIDERS,
PROVIDERS,
ProviderSpec,
canonical_provider_name,
find_by_model,
find_by_name,
find_gateway,
)
from deeptutor.services.videogen.config import VideogenConfig
from deeptutor.services.voice.config import (
AUTH_API_KEY_HEADER,
AUTH_BEARER,
STT_BASE64_JSON,
STT_MULTIPART,
STTConfig,
TTSConfig,
)
from .embedding_endpoint import (
EMBEDDING_PROVIDER_ALIASES,
EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS,
embedding_endpoint_validation_error,
normalize_embedding_endpoint_for_display,
)
from .loader import load_config_with_main
from .model_catalog import ModelCatalogService, get_model_catalog_service
SUPPORTED_SEARCH_PROVIDERS = {
"brave",
"tavily",
"jina",
"searxng",
"duckduckgo",
"perplexity",
"serper",
"none",
}
DEPRECATED_SEARCH_PROVIDERS = {"exa", "baidu", "openrouter"}
LLM_LOCALHOST_PROVIDERS = ("ollama", "vllm")
@dataclass(frozen=True)
class EmbeddingProviderSpec:
"""Single embedding-provider metadata entry.
Note on `default_api_base`: as of v1.3.0 this is the **fully-qualified
embedding endpoint URL** (e.g. ``https://api.openai.com/v1/embeddings``),
not a base. Adapters use the configured URL verbatim — no path appending.
"""
label: str
default_api_base: str
keywords: tuple[str, ...]
is_local: bool
adapter: str = "openai_compat"
mode: str = "standard"
default_model: str = ""
default_dim: int = 0
# Per-provider cap on items per embedding request batch. Adapters/clients
# clamp `batch_size` against this. SiliconFlow Qwen3 family caps at 32;
# DashScope caps at 20; most others have generous limits.
max_batch_items: int = 256
# Whether the active default model supports multimodal `contents` input.
multimodal: bool = False
EMBEDDING_PROVIDERS: dict[str, EmbeddingProviderSpec] = {
"openai": EmbeddingProviderSpec(
label="OpenAI",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["openai"],
keywords=("openai", "text-embedding", "ada-002", "embedding-3"),
is_local=False,
default_model="text-embedding-3-large",
default_dim=3072,
),
"gemini": EmbeddingProviderSpec(
label="Gemini",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["gemini"],
keywords=("gemini", "gemini-embedding", "text-embedding"),
is_local=False,
default_model="gemini-embedding-001",
default_dim=3072,
),
"azure_openai": EmbeddingProviderSpec(
label="Azure OpenAI",
mode="direct",
default_api_base="",
keywords=("azure", "aoai"),
is_local=False,
),
"cohere": EmbeddingProviderSpec(
label="Cohere",
adapter="cohere",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["cohere"],
keywords=("cohere", "embed-v4", "embed-english", "embed-multilingual"),
is_local=False,
default_model="embed-v4.0",
default_dim=1024,
multimodal=True,
),
"jina": EmbeddingProviderSpec(
label="Jina",
adapter="jina",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["jina"],
keywords=("jina", "jina-embeddings"),
is_local=False,
default_model="jina-embeddings-v3",
default_dim=1024,
),
"ollama": EmbeddingProviderSpec(
label="Ollama",
adapter="ollama",
mode="local",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["ollama"],
keywords=("ollama", "nomic-embed", "mxbai", "snowflake-arctic", "all-minilm"),
is_local=True,
default_model="nomic-embed-text",
default_dim=768,
),
"vllm": EmbeddingProviderSpec(
label="vLLM / LM Studio",
mode="local",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["vllm"],
keywords=("vllm", "lmstudio"),
is_local=True,
),
"siliconflow": EmbeddingProviderSpec(
label="SiliconFlow",
adapter="openai_compat",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["siliconflow"],
keywords=(
"siliconflow",
"qwen3-embedding",
"qwen3-vl-embedding",
"bge-m3",
"Pro/BAAI",
),
is_local=False,
default_model="Qwen/Qwen3-Embedding-8B",
default_dim=4096,
max_batch_items=32,
multimodal=True,
),
"aliyun": EmbeddingProviderSpec(
label="Aliyun DashScope",
adapter="dashscope_native",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["aliyun"],
keywords=("dashscope", "qwen3-vl-embedding", "qwen3-embedding", "aliyun", "bailian"),
is_local=False,
default_model="qwen3-vl-embedding",
default_dim=2560,
max_batch_items=20,
multimodal=True,
),
"custom": EmbeddingProviderSpec(
label="OpenAI Compatible",
mode="direct",
default_api_base="",
keywords=(),
is_local=False,
),
# Retained for legacy configs only. Public Settings providers use exact
# endpoint URLs and raw HTTP adapters so no request path is hidden.
"custom_openai_sdk": EmbeddingProviderSpec(
label="Custom (OpenAI SDK)",
adapter="openai_sdk",
mode="direct",
default_api_base="",
keywords=(),
is_local=False,
),
"openrouter": EmbeddingProviderSpec(
label="OpenRouter",
adapter="openai_compat",
default_api_base=EMBEDDING_PROVIDER_DEFAULT_ENDPOINTS["openrouter"],
keywords=("openrouter",),
is_local=False,
),
}
@dataclass(frozen=True)
class VoiceProviderSpec:
"""Metadata for one TTS or STT provider entry.
``default_api_base`` is the provider's **API base** (e.g.
``https://api.openai.com/v1``); the voice adapter appends ``/audio/speech``
or ``/audio/transcriptions``. ``adapter`` selects the HTTP adapter; the
OpenAI-compatible cluster all share ``openai_compat`` and differ only by
``auth_style`` (Azure uses ``api-key``) and STT ``request_style``
(OpenRouter uses base64-JSON).
"""
label: str
default_api_base: str
adapter: str = "openai_compat"
auth_style: str = AUTH_BEARER
default_model: str = ""
default_voice: str = "" # TTS only
request_style: str = STT_MULTIPART # STT only
is_local: bool = False
# Voice providers in the OpenAI-compatible cluster. A single adapter covers all
# of these; bespoke providers (DashScope native, ElevenLabs, Gemini, Deepgram)
# would register their own ``adapter`` value once implemented.
TTS_PROVIDERS: dict[str, VoiceProviderSpec] = {
"openai": VoiceProviderSpec(
label="OpenAI",
default_api_base="https://api.openai.com/v1",
default_model="gpt-4o-mini-tts",
default_voice="alloy",
),
"openrouter": VoiceProviderSpec(
label="OpenRouter",
default_api_base="https://openrouter.ai/api/v1",
adapter="openrouter_tts",
default_model="openai/gpt-4o-mini-tts",
default_voice="alloy",
),
"groq": VoiceProviderSpec(
label="Groq",
default_api_base="https://api.groq.com/openai/v1",
default_model="canopylabs/orpheus-v1-english",
default_voice="autumn",
),
"siliconflow": VoiceProviderSpec(
label="SiliconFlow",
default_api_base="https://api.siliconflow.cn/v1",
default_model="FunAudioLLM/CosyVoice2-0.5B",
default_voice="FunAudioLLM/CosyVoice2-0.5B:alex",
),
"azure_openai": VoiceProviderSpec(
label="Azure OpenAI",
default_api_base="",
auth_style=AUTH_API_KEY_HEADER,
default_model="tts-1",
default_voice="alloy",
),
"vllm": VoiceProviderSpec(
label="vLLM / Local",
default_api_base="http://localhost:8000/v1",
default_model="",
default_voice="",
is_local=True,
),
"custom": VoiceProviderSpec(
label="OpenAI Compatible",
default_api_base="",
default_model="",
default_voice="",
),
}
STT_PROVIDERS: dict[str, VoiceProviderSpec] = {
"openai": VoiceProviderSpec(
label="OpenAI",
default_api_base="https://api.openai.com/v1",
default_model="gpt-4o-mini-transcribe",
),
"openrouter": VoiceProviderSpec(
label="OpenRouter",
default_api_base="https://openrouter.ai/api/v1",
default_model="openai/whisper-large-v3",
request_style=STT_BASE64_JSON,
),
"groq": VoiceProviderSpec(
label="Groq",
default_api_base="https://api.groq.com/openai/v1",
default_model="whisper-large-v3-turbo",
),
"siliconflow": VoiceProviderSpec(
label="SiliconFlow",
default_api_base="https://api.siliconflow.cn/v1",
default_model="FunAudioLLM/SenseVoiceSmall",
),
"azure_openai": VoiceProviderSpec(
label="Azure OpenAI",
default_api_base="",
auth_style=AUTH_API_KEY_HEADER,
default_model="whisper-1",
),
"vllm": VoiceProviderSpec(
label="vLLM / Local",
default_api_base="http://localhost:8000/v1",
default_model="",
is_local=True,
),
"custom": VoiceProviderSpec(
label="OpenAI Compatible",
default_api_base="",
default_model="",
),
}
# Provider-name aliases accepted from older/loose catalog values.
VOICE_PROVIDER_ALIASES = {
"azure": "azure_openai",
"aoai": "azure_openai",
"openai_compatible": "custom",
"lmstudio": "vllm",
}
def _canonical_voice_provider(name: str | None, table: dict[str, VoiceProviderSpec]) -> str:
key = (name or "").strip().lower().replace("-", "_")
key = VOICE_PROVIDER_ALIASES.get(key, key)
return key if key in table else "custom"
@dataclass(frozen=True)
class GenerationProviderSpec:
"""Metadata for one image- or video-generation provider entry.
``default_api_base`` is the provider's **API base** (e.g.
``https://api.openai.com/v1`` or ``https://ark.cn-beijing.volces.com/api/v3``);
the adapter appends the relative path (``images/generations`` or
``contents/generations/tasks``). ``adapter`` selects the HTTP adapter:
imagegen providers share ``openai_compat``; videogen task-style providers
use ``async_task``.
"""
label: str
default_api_base: str
adapter: str = "openai_compat"
auth_style: str = AUTH_BEARER
default_model: str = ""
is_local: bool = False
# Image-generation providers in the OpenAI-compatible cluster. A single adapter
# covers all of these; ``default_model`` is only a Settings prefill hint.
IMAGEGEN_PROVIDERS: dict[str, GenerationProviderSpec] = {
"openai": GenerationProviderSpec(
label="OpenAI",
default_api_base="https://api.openai.com/v1",
default_model="gpt-image-1",
),
"volcengine": GenerationProviderSpec(
label="Volcengine Ark (Seedream)",
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
default_model="doubao-seedream-3-0-t2i-250415",
),
"siliconflow": GenerationProviderSpec(
label="SiliconFlow",
default_api_base="https://api.siliconflow.cn/v1",
default_model="Kwai-Kolors/Kolors",
),
# OpenRouter generates images through /chat/completions (modalities), not the
# OpenAI Images API — so it uses the chat_completions adapter, not openai_compat.
"openrouter": GenerationProviderSpec(
label="OpenRouter",
default_api_base="https://openrouter.ai/api/v1",
adapter="chat_completions",
default_model="google/gemini-2.5-flash-image-preview",
),
"azure_openai": GenerationProviderSpec(
label="Azure OpenAI",
default_api_base="",
auth_style=AUTH_API_KEY_HEADER,
default_model="dall-e-3",
),
"custom": GenerationProviderSpec(
label="OpenAI Compatible",
default_api_base="",
default_model="",
),
# Generic chat-completions image output (any OpenRouter-style gateway).
"custom_chat": GenerationProviderSpec(
label="Chat Completions (Custom)",
default_api_base="",
adapter="chat_completions",
default_model="",
),
}
# Video-generation providers. Text-to-video has no synchronous standard; these
# all use the async-task adapter (submit → poll → download).
VIDEOGEN_PROVIDERS: dict[str, GenerationProviderSpec] = {
"volcengine": GenerationProviderSpec(
label="Volcengine Ark (Seedance)",
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
adapter="async_task",
default_model="doubao-seedance-1-0-pro-250528",
),
"custom": GenerationProviderSpec(
label="Async Task (Custom)",
default_api_base="",
adapter="async_task",
default_model="",
),
}
# Provider-name aliases accepted from older/loose catalog values.
GENERATION_PROVIDER_ALIASES = {
"ark": "volcengine",
"volces": "volcengine",
"doubao": "volcengine",
"seedream": "volcengine",
"seedance": "volcengine",
"azure": "azure_openai",
"aoai": "azure_openai",
"openai_compatible": "custom",
}
def _canonical_generation_provider(
name: str | None, table: dict[str, GenerationProviderSpec]
) -> str:
key = (name or "").strip().lower().replace("-", "_")
key = GENERATION_PROVIDER_ALIASES.get(key, key)
return key if key in table else "custom"
@dataclass(slots=True)
class NormalizedProviderConfig:
"""Normalized provider configuration input."""
name: str
api_key: str = ""
api_base: str | None = None
api_version: str | None = None
extra_headers: dict[str, str] | None = None
@dataclass(slots=True)
class ResolvedLLMConfig:
"""Resolved runtime LLM config used by get_llm_config/factory."""
model: str
provider_name: str
provider_mode: str
binding_hint: str | None = None
binding: str = "openai"
api_key: str = ""
base_url: str | None = None
effective_url: str | None = None
api_version: str | None = None
extra_headers: dict[str, str] = field(default_factory=dict)
reasoning_effort: str | None = None
context_window: int | None = None
@dataclass(slots=True)
class ResolvedEmbeddingConfig:
"""Resolved runtime embedding config."""
model: str
provider_name: str
provider_mode: str
binding_hint: str | None = None
binding: str = "openai"
api_key: str = ""
base_url: str | None = None
effective_url: str | None = None
api_version: str | None = None
extra_headers: dict[str, str] = field(default_factory=dict)
dimension: int = 0
send_dimensions: bool | None = None
request_timeout: int = 60
batch_size: int = 10
batch_delay: float = 0.0
@dataclass(slots=True)
class ResolvedSearchConfig:
"""Resolved runtime web-search config."""
provider: str
requested_provider: str
api_key: str = ""
base_url: str = ""
max_results: int = 5
proxy: str | None = None
unsupported_provider: bool = False
deprecated_provider: bool = False
missing_credentials: bool = False
fallback_reason: str | None = None
@property
def status(self) -> str:
if self.unsupported_provider:
return "unsupported"
if self.deprecated_provider:
return "deprecated"
if self.missing_credentials:
return "missing_credentials"
if self.fallback_reason:
return "fallback"
return "ok"
def _as_str(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _to_headers(value: Any) -> dict[str, str]:
if isinstance(value, dict):
return {str(k): str(v) for k, v in value.items() if str(k).strip() and v is not None}
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return {str(k): str(v) for k, v in parsed.items() if str(k).strip() and v is not None}
return {}
def _is_local_base_url(base_url: str | None) -> bool:
if not base_url:
return False
try:
parsed = urlparse(base_url if "://" in base_url else f"http://{base_url}")
except Exception:
return False
host = (parsed.hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1"} or host.endswith(".local")
def _load_catalog(catalog: dict[str, Any] | None) -> dict[str, Any]:
if catalog is not None:
return catalog
return get_model_catalog_service().load()
def _active_profile_and_model(
catalog: dict[str, Any],
service: ModelCatalogService,
service_name: str,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
profile = service.get_active_profile(catalog, service_name)
model = service.get_active_model(catalog, service_name)
return profile, model
def _collect_provider_pool(catalog: dict[str, Any]) -> dict[str, NormalizedProviderConfig]:
providers: dict[str, NormalizedProviderConfig] = {}
llm_profiles = catalog.get("services", {}).get("llm", {}).get("profiles", [])
for profile in llm_profiles:
name = canonical_provider_name(_as_str(profile.get("binding")))
if not name:
continue
providers[name] = NormalizedProviderConfig(
name=name,
api_key=_as_str(profile.get("api_key")),
api_base=_as_str(profile.get("base_url")) or None,
api_version=_as_str(profile.get("api_version")) or None,
extra_headers=_to_headers(profile.get("extra_headers")) or None,
)
return providers
def _choose_resolved_provider(
*,
hint: str | None,
model: str,
api_key: str,
api_base: str | None,
provider_pool: dict[str, NormalizedProviderConfig],
) -> ProviderSpec:
explicit_spec = find_by_name(hint) if hint else None
detected_gateway = find_gateway(
provider_name=None,
api_key=api_key or None,
api_base=api_base or None,
)
# Keep backward compatibility: old `binding=openai` should not block
# gateway detection when key/base clearly indicates a gateway provider.
if explicit_spec and detected_gateway and explicit_spec.name == "openai":
return detected_gateway
if explicit_spec:
return explicit_spec
if detected_gateway:
return detected_gateway
model_spec = find_by_model(model)
if model_spec:
return model_spec
if _is_local_base_url(api_base):
if api_base and "11434" in api_base:
return find_by_name("ollama") or find_by_name("vllm") or find_by_name("openai")
return find_by_name("vllm") or find_by_name("ollama") or find_by_name("openai")
for spec in PROVIDERS:
configured = provider_pool.get(spec.name)
if not configured:
continue
if spec.is_gateway and (configured.api_key or configured.api_base):
return spec
for spec in PROVIDERS:
configured = provider_pool.get(spec.name)
if not configured:
continue
if spec.is_local and configured.api_base:
return spec
if not spec.is_oauth and configured.api_key:
return spec
return find_by_name("openai") or PROVIDERS[0]
def resolve_llm_runtime_config(
catalog: dict[str, Any] | None = None,
*,
service: ModelCatalogService | None = None,
llm_selection: dict[str, Any] | LLMSelection | None = None,
) -> ResolvedLLMConfig:
"""Resolve active LLM config with TutorBot-style provider matching."""
catalog_service = service or get_model_catalog_service()
loaded = _load_catalog(catalog)
loaded = apply_llm_selection_to_catalog(loaded, llm_selection)
profile, model = _active_profile_and_model(loaded, catalog_service, "llm")
resolved_model = _as_str((model or {}).get("model"))
if not resolved_model:
resolved_model = "gpt-4o-mini"
binding_hint_raw = _as_str((profile or {}).get("binding"))
binding_hint = canonical_provider_name(binding_hint_raw)
active_api_key = _as_str((profile or {}).get("api_key"))
active_api_base = _as_str((profile or {}).get("base_url"))
active_api_version = _as_str((profile or {}).get("api_version"))
reasoning_effort = _as_str((model or {}).get("reasoning_effort")) or None
active_extra_headers = _to_headers((profile or {}).get("extra_headers"))
context_window = _coerce_optional_int((model or {}).get("context_window"))
if context_window is None:
context_window = _coerce_optional_int((model or {}).get("context_window_tokens"))
provider_pool = _collect_provider_pool(loaded)
spec = _choose_resolved_provider(
hint=binding_hint,
model=resolved_model,
api_key=active_api_key,
api_base=active_api_base or None,
provider_pool=provider_pool,
)
mapped = provider_pool.get(spec.name)
api_key = active_api_key or (mapped.api_key if mapped else "")
api_base = active_api_base or ((mapped.api_base or "") if mapped else "")
api_version = active_api_version or ((mapped.api_version or "") if mapped else "")
if not api_base and spec.default_api_base:
api_base = spec.default_api_base
if not api_key and spec.is_local:
api_key = "sk-no-key-required"
extra_headers = active_extra_headers or ((mapped.extra_headers or {}) if mapped else {})
return ResolvedLLMConfig(
model=resolved_model,
provider_name=spec.name,
provider_mode=spec.mode,
binding_hint=binding_hint,
binding=spec.name,
api_key=api_key,
base_url=api_base or None,
effective_url=api_base or None,
api_version=api_version or None,
extra_headers=extra_headers,
reasoning_effort=reasoning_effort,
context_window=context_window,
)
def _canonical_embedding_provider_name(name: str | None) -> str | None:
if not name:
return None
key = name.strip().replace("-", "_")
if not key:
return None
key = EMBEDDING_PROVIDER_ALIASES.get(key, key)
key = canonical_provider_name(key) or key
key = EMBEDDING_PROVIDER_ALIASES.get(key, key)
if key in EMBEDDING_PROVIDERS:
return key
return None
def _collect_embedding_provider_pool(
catalog: dict[str, Any],
) -> dict[str, NormalizedProviderConfig]:
providers: dict[str, NormalizedProviderConfig] = {}
embedding_profiles = catalog.get("services", {}).get("embedding", {}).get("profiles", [])
for profile in embedding_profiles:
name = _canonical_embedding_provider_name(_as_str(profile.get("binding")))
if not name:
continue
providers[name] = NormalizedProviderConfig(
name=name,
api_key=_as_str(profile.get("api_key")),
api_base=_as_str(profile.get("base_url")) or None,
api_version=_as_str(profile.get("api_version")) or None,
extra_headers=_to_headers(profile.get("extra_headers")) or None,
)
return providers
def _resolve_embedding_dimension(value: Any, default: int = 0) -> int:
"""Parse the dimension value. Returns 0 when unknown/unparseable.
A value of 0 means "use the provider's native default" downstream;
test_runner auto-fills the catalog with the actual response dim on
first successful connection test.
"""
try:
parsed = int(str(value).strip())
except (TypeError, ValueError):
return default
if parsed <= 0:
return default
return parsed
def _coerce_optional_bool(value: Any) -> bool | None:
"""Parse a tri-state bool from catalog values.
Returns ``True``/``False`` for explicit values and ``None`` for missing,
empty, or unrecognised inputs (which means "use the default behaviour").
"""
if value is None:
return None
if isinstance(value, bool):
return value
text = str(value).strip().lower()
if not text:
return None
if text in {"true", "1", "yes", "on"}:
return True
if text in {"false", "0", "no", "off"}:
return False
return None
def _coerce_optional_int(value: Any) -> int | None:
"""Parse a positive int from catalog values, returning ``None`` when unset."""
if value is None:
return None
try:
parsed = int(str(value).strip())
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _resolve_embedding_provider(
*,
hint: str | None,
model: str,
api_base: str | None,
provider_pool: dict[str, NormalizedProviderConfig],
) -> str:
if hint and hint in EMBEDDING_PROVIDERS:
return hint
model_lower = (model or "").lower()
model_prefix = model_lower.split("/", 1)[0].replace("-", "_") if "/" in model_lower else ""
if model_prefix in EMBEDDING_PROVIDERS:
return model_prefix
for provider_name, spec in EMBEDDING_PROVIDERS.items():
if any(keyword in model_lower for keyword in spec.keywords):
return provider_name
if _is_local_base_url(api_base):
if api_base and "11434" in api_base:
return "ollama"
return "vllm"
for provider_name, spec in EMBEDDING_PROVIDERS.items():
configured = provider_pool.get(provider_name)
if not configured:
continue
if spec.is_local and configured.api_base:
return provider_name
if configured.api_key:
return provider_name
return "openai"
def resolve_embedding_runtime_config(
catalog: dict[str, Any] | None = None,
*,
service: ModelCatalogService | None = None,
) -> ResolvedEmbeddingConfig:
"""Resolve active embedding config using provider-runtime normalization."""
catalog_service = service or get_model_catalog_service()
loaded = _load_catalog(catalog)
profile, model = _active_profile_and_model(loaded, catalog_service, "embedding")
resolved_model = _as_str((model or {}).get("model"))
if not resolved_model:
raise ValueError(
"No active embedding model is configured. Please set it in Settings > Catalog."
)
binding_hint_raw = _as_str((profile or {}).get("binding"))
binding_hint = _canonical_embedding_provider_name(binding_hint_raw)
active_api_key = _as_str((profile or {}).get("api_key"))
active_api_base = _as_str((profile or {}).get("base_url"))
active_api_version = _as_str((profile or {}).get("api_version"))
active_extra_headers = _to_headers((profile or {}).get("extra_headers"))
# Default 0 means "not yet known" — the test_runner auto-fills on first
# successful connection. Adapters/clients should treat 0 as "let the
# provider use its native default". 3072 used to be hard-coded here, which
# forced every non-OpenAI provider to fail dim validation on first use.
dimension = _resolve_embedding_dimension((model or {}).get("dimension") or 0, default=0)
# ``None`` means "fall back to adapter heuristic".
send_dimensions = _coerce_optional_bool((model or {}).get("send_dimensions"))
provider_pool = _collect_embedding_provider_pool(loaded)
provider_name = _resolve_embedding_provider(
hint=binding_hint,
model=resolved_model,
api_base=active_api_base or None,
provider_pool=provider_pool,
)
spec = EMBEDDING_PROVIDERS[provider_name]
mapped = provider_pool.get(provider_name)
api_key = active_api_key or (mapped.api_key if mapped else "")
api_base = active_api_base or ((mapped.api_base or "") if mapped else "")
if not api_base and spec.default_api_base:
api_base = spec.default_api_base
api_version = active_api_version or ((mapped.api_version or "") if mapped else "")
extra_headers = active_extra_headers or ((mapped.extra_headers or {}) if mapped else {})
return ResolvedEmbeddingConfig(
model=resolved_model,
provider_name=provider_name,
provider_mode=spec.mode,
binding_hint=binding_hint,
binding=provider_name,
api_key=api_key,
base_url=api_base or None,
effective_url=api_base or None,
api_version=api_version or None,
extra_headers=extra_headers,
dimension=dimension,
send_dimensions=send_dimensions,
request_timeout=60,
batch_size=10,
batch_delay=0.0,
)
def _coerce_optional_float(value: Any) -> float | None:
"""Parse a positive float from catalog values, returning ``None`` when unset."""
if value is None or (isinstance(value, str) and not value.strip()):
return None
try:
parsed = float(str(value).strip())
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def resolve_tts_runtime_config(
catalog: dict[str, Any] | None = None,
*,
service: ModelCatalogService | None = None,
) -> TTSConfig:
"""Resolve the active text-to-speech config from the model catalog."""
catalog_service = service or get_model_catalog_service()
loaded = _load_catalog(catalog)
profile, model = _active_profile_and_model(loaded, catalog_service, "tts")
resolved_model = _as_str((model or {}).get("model"))
if not resolved_model:
raise ValueError("No active TTS model is configured. Set it in Settings > Voice.")
provider = _canonical_voice_provider(_as_str((profile or {}).get("binding")), TTS_PROVIDERS)
spec = TTS_PROVIDERS[provider]
api_base = _as_str((profile or {}).get("base_url")) or spec.default_api_base
api_key = _as_str((profile or {}).get("api_key"))
if not api_key and spec.is_local:
api_key = "sk-no-key-required"
voice = _as_str((model or {}).get("voice")) or spec.default_voice
response_format = _as_str((model or {}).get("response_format")) or "mp3"
return TTSConfig(
model=resolved_model,
provider_name=provider,
adapter=spec.adapter,
auth_style=spec.auth_style,
api_key=api_key,
base_url=api_base,
api_version=_as_str((profile or {}).get("api_version")) or None,
extra_headers=_to_headers((profile or {}).get("extra_headers")),
voice=voice,
response_format=response_format,
speed=_coerce_optional_float((model or {}).get("speed")),
)
def resolve_stt_runtime_config(
catalog: dict[str, Any] | None = None,
*,
service: ModelCatalogService | None = None,
) -> STTConfig:
"""Resolve the active speech-to-text config from the model catalog."""
catalog_service = service or get_model_catalog_service()
loaded = _load_catalog(catalog)
profile, model = _active_profile_and_model(loaded, catalog_service, "stt")
resolved_model = _as_str((model or {}).get("model"))
if not resolved_model:
raise ValueError("No active STT model is configured. Set it in Settings > Voice.")
provider = _canonical_voice_provider(_as_str((profile or {}).get("binding")), STT_PROVIDERS)
spec = STT_PROVIDERS[provider]
api_base = _as_str((profile or {}).get("base_url")) or spec.default_api_base
api_key = _as_str((profile or {}).get("api_key"))
if not api_key and spec.is_local:
api_key = "sk-no-key-required"
return STTConfig(
model=resolved_model,
provider_name=provider,
adapter=spec.adapter,
request_style=spec.request_style,
auth_style=spec.auth_style,
api_key=api_key,
base_url=api_base,
api_version=_as_str((profile or {}).get("api_version")) or None,
extra_headers=_to_headers((profile or {}).get("extra_headers")),
language=_as_str((model or {}).get("language")) or None,
)
def resolve_imagegen_runtime_config(
catalog: dict[str, Any] | None = None,
*,
service: ModelCatalogService | None = None,
) -> ImagegenConfig:
"""Resolve the active text-to-image config from the model catalog."""
catalog_service = service or get_model_catalog_service()
loaded = _load_catalog(catalog)
profile, model = _active_profile_and_model(loaded, catalog_service, "imagegen")
resolved_model = _as_str((model or {}).get("model"))
if not resolved_model:
raise ValueError(
"No active image-generation model is configured. "
"Set it in Settings > Media Generation > Image Generation."
)
provider = _canonical_generation_provider(
_as_str((profile or {}).get("binding")), IMAGEGEN_PROVIDERS
)
spec = IMAGEGEN_PROVIDERS[provider]
api_base = _as_str((profile or {}).get("base_url")) or spec.default_api_base
api_key = _as_str((profile or {}).get("api_key"))
if not api_key and spec.is_local:
api_key = "sk-no-key-required"
return ImagegenConfig(
model=resolved_model,
provider_name=provider,
adapter=spec.adapter,
auth_style=spec.auth_style,
api_key=api_key,
base_url=api_base,
api_version=_as_str((profile or {}).get("api_version")) or None,
extra_headers=_to_headers((profile or {}).get("extra_headers")),
size=_as_str((model or {}).get("size")),
quality=_as_str((model or {}).get("quality")),
style=_as_str((model or {}).get("style")),
response_format=_as_str((model or {}).get("response_format")),
)
def resolve_videogen_runtime_config(
catalog: dict[str, Any] | None = None,
*,
service: ModelCatalogService | None = None,
) -> VideogenConfig:
"""Resolve the active text-to-video config from the model catalog."""
catalog_service = service or get_model_catalog_service()
loaded = _load_catalog(catalog)
profile, model = _active_profile_and_model(loaded, catalog_service, "videogen")
resolved_model = _as_str((model or {}).get("model"))
if not resolved_model:
raise ValueError(
"No active video-generation model is configured. "
"Set it in Settings > Media Generation > Video Generation."
)
provider = _canonical_generation_provider(
_as_str((profile or {}).get("binding")), VIDEOGEN_PROVIDERS
)
spec = VIDEOGEN_PROVIDERS[provider]
api_base = _as_str((profile or {}).get("base_url")) or spec.default_api_base
api_key = _as_str((profile or {}).get("api_key"))
if not api_key and spec.is_local:
api_key = "sk-no-key-required"
return VideogenConfig(
model=resolved_model,
provider_name=provider,
adapter=spec.adapter,
auth_style=spec.auth_style,
api_key=api_key,
base_url=api_base,
api_version=_as_str((profile or {}).get("api_version")) or None,
extra_headers=_to_headers((profile or {}).get("extra_headers")),
aspect_ratio=_as_str((model or {}).get("aspect_ratio")),
duration=_as_str((model or {}).get("duration")),
resolution=_as_str((model or {}).get("resolution")),
)
def _resolve_search_max_results(catalog: dict[str, Any], default: int = 5) -> int:
profile = get_model_catalog_service().get_active_profile(catalog, "search") or {}
raw = profile.get("max_results")
if raw is not None:
try:
value = int(raw)
return max(1, min(value, 10))
except (TypeError, ValueError):
pass
try:
settings = load_config_with_main("main.yaml")
except Exception:
return default
tools = settings.get("tools", {}) if isinstance(settings, dict) else {}
web_search = tools.get("web_search", {}) if isinstance(tools, dict) else {}
if isinstance(web_search, dict):
raw = web_search.get("max_results")
if raw is not None:
try:
value = int(raw)
return max(1, min(value, 10))
except (TypeError, ValueError):
pass
web = tools.get("web", {}) if isinstance(tools, dict) else {}
search = web.get("search", {}) if isinstance(web, dict) else {}
raw = search.get("max_results") if isinstance(search, dict) else None
if raw is None:
return default
try:
value = int(raw)
return max(1, min(value, 10))
except (TypeError, ValueError):
return default
def resolve_search_runtime_config(
catalog: dict[str, Any] | None = None,
*,
service: ModelCatalogService | None = None,
) -> ResolvedSearchConfig:
"""Resolve active web-search config with TutorBot-style fallback behavior."""
catalog_service = service or get_model_catalog_service()
loaded = _load_catalog(catalog)
profile = catalog_service.get_active_profile(loaded, "search") or {}
requested_provider = (_as_str(profile.get("provider")) or "duckduckgo").lower()
provider = requested_provider
api_key = _as_str(profile.get("api_key"))
base_url = _as_str(profile.get("base_url"))
proxy = _as_str(profile.get("proxy")) or None
max_results = _resolve_search_max_results(loaded)
deprecated = provider in DEPRECATED_SEARCH_PROVIDERS
unsupported = provider not in SUPPORTED_SEARCH_PROVIDERS
fallback_reason: str | None = None
missing_credentials = False
if provider == "none":
return ResolvedSearchConfig(
provider="none",
requested_provider="none",
api_key="",
base_url="",
max_results=max_results,
proxy=proxy,
)
if provider in {"perplexity", "serper"} and not api_key:
missing_credentials = True
if unsupported:
return ResolvedSearchConfig(
provider=provider,
requested_provider=requested_provider,
api_key=api_key,
base_url=base_url,
max_results=max_results,
proxy=proxy,
unsupported_provider=True,
deprecated_provider=deprecated,
missing_credentials=missing_credentials,
)
if provider in {"brave", "tavily", "jina"} and not api_key:
fallback_reason = f"{provider} requires api_key, falling back to duckduckgo"
provider = "duckduckgo"
elif provider == "searxng" and not base_url:
fallback_reason = "searxng requires base_url, falling back to duckduckgo"
provider = "duckduckgo"
return ResolvedSearchConfig(
provider=provider,
requested_provider=requested_provider,
api_key=api_key,
base_url=base_url,
max_results=max_results,
proxy=proxy,
unsupported_provider=False,
deprecated_provider=deprecated,
missing_credentials=missing_credentials,
fallback_reason=fallback_reason,
)
def search_provider_state(provider: str | None) -> str:
"""Return provider status class for UI/CLI/system output."""
value = (provider or "").strip().lower()
if not value:
return "not_configured"
if value in DEPRECATED_SEARCH_PROVIDERS:
return "deprecated"
if value not in SUPPORTED_SEARCH_PROVIDERS:
return "unsupported"
return "supported"
__all__ = [
"SUPPORTED_SEARCH_PROVIDERS",
"DEPRECATED_SEARCH_PROVIDERS",
"NANOBOT_LLM_PROVIDERS",
"EmbeddingProviderSpec",
"EMBEDDING_PROVIDERS",
"VoiceProviderSpec",
"TTS_PROVIDERS",
"STT_PROVIDERS",
"resolve_tts_runtime_config",
"resolve_stt_runtime_config",
"GenerationProviderSpec",
"IMAGEGEN_PROVIDERS",
"VIDEOGEN_PROVIDERS",
"resolve_imagegen_runtime_config",
"resolve_videogen_runtime_config",
"EMBEDDING_PROVIDER_ALIASES",
"embedding_endpoint_validation_error",
"normalize_embedding_endpoint_for_display",
"NormalizedProviderConfig",
"ResolvedLLMConfig",
"ResolvedEmbeddingConfig",
"ResolvedSearchConfig",
"LLM_LOCALHOST_PROVIDERS",
"resolve_llm_runtime_config",
"resolve_embedding_runtime_config",
"resolve_search_runtime_config",
"search_provider_state",
]