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

294 lines
8.2 KiB
Python

"""
LLM Configuration
=================
Configuration management for LLM services.
Loads from data/user/settings/model_catalog.json.
"""
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import dataclass, replace
import logging
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING, TypedDict
from deeptutor.services.config import resolve_llm_runtime_config
from deeptutor.services.provider_registry import canonical_provider_name, find_by_name
from .exceptions import LLMConfigError
if TYPE_CHECKING:
from .traffic_control import TrafficController
class LLMConfigUpdate(TypedDict, total=False):
"""Fields allowed when cloning an LLMConfig instance."""
model: str
api_key: str
base_url: str | None
effective_url: str | None
binding: str
provider_name: str
provider_mode: str
api_version: str | None
extra_headers: dict[str, str]
reasoning_effort: str | None
context_window: int | None
max_tokens: int
temperature: float
max_concurrency: int
requests_per_minute: int
traffic_controller: "TrafficController" | None
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).resolve().parents[3]
def _is_openai_compatible_binding(binding: str | None) -> bool:
canonical = canonical_provider_name(binding) or (binding or "").strip().lower()
spec = find_by_name(canonical)
if not spec or spec.is_oauth:
return False
return spec.backend in {"openai_compat", "azure_openai"}
def _set_openai_env_vars(api_key: str | None, base_url: str | None, *, source: str) -> None:
if api_key:
os.environ["OPENAI_API_KEY"] = api_key
logger.debug("Set OPENAI_API_KEY env var (%s)", source)
if base_url:
from .utils import sanitize_url
clean_url = sanitize_url(base_url)
os.environ["OPENAI_BASE_URL"] = clean_url
logger.debug("Set OPENAI_BASE_URL env var to %s (%s)", clean_url, source)
def _setup_openai_env_vars_early() -> None:
"""
Set OPENAI_* environment variables early for OpenAI-compatible SDKs.
Some SDK helpers read credentials/endpoints from process environment.
This is called at module import time so downstream calls have consistent
environment regardless of entrypoint.
"""
try:
resolved = resolve_llm_runtime_config()
except Exception:
return
if _is_openai_compatible_binding(resolved.binding):
_set_openai_env_vars(resolved.api_key, resolved.effective_url, source="early init")
# Execute early setup at module import time
_setup_openai_env_vars_early()
@dataclass
class LLMConfig:
"""LLM configuration dataclass."""
model: str
api_key: str
base_url: str | None = None
effective_url: str | None = None
binding: str = "openai"
provider_name: str = "routing"
provider_mode: str = "standard"
api_version: str | None = None
extra_headers: dict[str, str] | None = None
reasoning_effort: str | None = None
context_window: int | None = None
max_tokens: int = 4096
temperature: float = 0.7
max_concurrency: int = 20
requests_per_minute: int = 600
traffic_controller: TrafficController | None = None
def __post_init__(self) -> None:
if self.effective_url is None:
self.effective_url = self.base_url
def model_copy(self, update: LLMConfigUpdate | None = None) -> "LLMConfig":
"""Return a copy of the config with optional updates."""
return replace(self, **(update or {}))
def get_api_key(self) -> str:
"""Return the API key string for provider consumers."""
return self.api_key
_LLM_CONFIG_CACHE: LLMConfig | None = None
_SCOPED_LLM_CONFIG: ContextVar[LLMConfig | None] = ContextVar(
"deeptutor_scoped_llm_config",
default=None,
)
def set_scoped_llm_config(config: LLMConfig | None) -> Token[LLMConfig | None]:
"""Set the LLM config for the current async context."""
return _SCOPED_LLM_CONFIG.set(config)
def reset_scoped_llm_config(token: Token[LLMConfig | None]) -> None:
"""Reset a scoped LLM config token returned by ``set_scoped_llm_config``."""
_SCOPED_LLM_CONFIG.reset(token)
def initialize_environment() -> None:
"""
Explicitly initialize environment variables for compatibility.
This should be called during application startup to keep OPENAI_* env vars
aligned with current config values.
"""
resolved = resolve_llm_runtime_config()
if _is_openai_compatible_binding(resolved.binding):
_set_openai_env_vars(
resolved.api_key,
resolved.effective_url,
source="initialize_environment",
)
def _get_llm_config_from_resolver() -> LLMConfig:
"""Resolve LLM config from the TutorBot-style runtime adapter."""
resolved = resolve_llm_runtime_config()
if not resolved.model:
raise LLMConfigError(
"No active LLM model is configured. Please set it in Settings > Catalog."
)
if not resolved.effective_url and resolved.provider_mode != "oauth":
raise LLMConfigError(
"No effective LLM endpoint resolved. Please configure base_url or provider defaults."
)
return LLMConfig(
model=resolved.model,
api_key=resolved.api_key,
base_url=resolved.base_url,
effective_url=resolved.effective_url,
binding=resolved.binding,
provider_name=resolved.provider_name,
provider_mode=resolved.provider_mode,
api_version=resolved.api_version,
extra_headers=resolved.extra_headers,
reasoning_effort=resolved.reasoning_effort,
context_window=resolved.context_window,
)
def get_llm_config() -> LLMConfig:
"""
Load LLM configuration.
Returns:
LLMConfig: Configuration dataclass
Raises:
LLMConfigError: If required configuration is missing
"""
global _LLM_CONFIG_CACHE
scoped = _SCOPED_LLM_CONFIG.get()
if scoped is not None:
return scoped
if _LLM_CONFIG_CACHE is not None:
return _LLM_CONFIG_CACHE
_LLM_CONFIG_CACHE = _get_llm_config_from_resolver()
return _LLM_CONFIG_CACHE
async def get_llm_config_async() -> LLMConfig:
"""
Async wrapper for get_llm_config.
Useful for consistency in async contexts, though the underlying load is synchronous.
Returns:
LLMConfig: Configuration dataclass
"""
return get_llm_config()
def clear_llm_config_cache() -> None:
"""Clear cached LLM configuration."""
global _LLM_CONFIG_CACHE
_LLM_CONFIG_CACHE = None
def reload_config() -> LLMConfig:
"""Reload and return the LLM configuration."""
clear_llm_config_cache()
return get_llm_config()
def uses_max_completion_tokens(model: str) -> bool:
"""
Check if the model uses max_completion_tokens instead of max_tokens.
Newer OpenAI models (o1, o3, gpt-4o, gpt-5.x, etc.) require max_completion_tokens
while older models use max_tokens.
Args:
model: The model name
Returns:
True if the model requires max_completion_tokens, False otherwise
"""
model_lower = model.lower()
# Models that require max_completion_tokens:
# - o1, o3 series (reasoning models)
# - gpt-4o series
# - gpt-5.x and later
patterns = [
r"^o\d", # o1, o3, o4-mini, o4, and future o-series models
r"^gpt-4o", # gpt-4o models
r"^gpt-[5-9]", # gpt-5.x and later
r"^gpt-\d{2,}", # gpt-10+ (future proofing)
]
for pattern in patterns:
if re.match(pattern, model_lower):
return True
return False
def get_token_limit_kwargs(model: str, max_tokens: int) -> dict[str, int]:
"""
Get the appropriate token limit parameter for the model.
Args:
model: The model name
max_tokens: The desired token limit
Returns:
Dictionary with either {"max_tokens": value} or {"max_completion_tokens": value}
"""
if uses_max_completion_tokens(model):
return {"max_completion_tokens": max_tokens}
return {"max_tokens": max_tokens}
__all__ = [
"LLMConfig",
"get_llm_config",
"get_llm_config_async",
"clear_llm_config_cache",
"reload_config",
"uses_max_completion_tokens",
"get_token_limit_kwargs",
]