初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,31 @@
|
||||
from app.ai_provider.config.loader import (
|
||||
get_models_config_path,
|
||||
get_pricing_config_path,
|
||||
get_providers_config_path,
|
||||
load_models_config,
|
||||
load_pricing_config,
|
||||
load_providers_config,
|
||||
)
|
||||
from app.ai_provider.config.models import (
|
||||
ModelConfig,
|
||||
ModelEndpointConfig,
|
||||
ParameterMapping,
|
||||
ProviderConfig,
|
||||
derive_capabilities,
|
||||
)
|
||||
from app.ai_provider.core.billing import PricingInfo
|
||||
|
||||
__all__ = [
|
||||
"ModelConfig",
|
||||
"ModelEndpointConfig",
|
||||
"PricingInfo",
|
||||
"ProviderConfig",
|
||||
"ParameterMapping",
|
||||
"derive_capabilities",
|
||||
"load_models_config",
|
||||
"load_providers_config",
|
||||
"load_pricing_config",
|
||||
"get_models_config_path",
|
||||
"get_providers_config_path",
|
||||
"get_pricing_config_path",
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.ai_provider.config.models import ModelConfig, ProviderConfig
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class _ModelsFile(BaseModel):
|
||||
version: int = 1
|
||||
models: list[ModelConfig]
|
||||
|
||||
|
||||
class _ProvidersFile(BaseModel):
|
||||
version: int = 1
|
||||
providers: dict[str, ProviderConfig]
|
||||
|
||||
|
||||
class _PricingFile(BaseModel):
|
||||
"""pricing.yaml 顶层结构。"""
|
||||
|
||||
version: int = 1
|
||||
pricing: dict[str, dict] = {}
|
||||
|
||||
|
||||
def get_models_config_path() -> Path:
|
||||
"""返回已解析的 models.yaml 路径(project_root/config/models.yaml)。"""
|
||||
return settings.project_root / "config" / "models.yaml"
|
||||
|
||||
|
||||
def get_providers_config_path() -> Path:
|
||||
"""返回已解析的 providers.yaml 路径(project_root/config/providers.yaml)。"""
|
||||
return settings.project_root / "config" / "providers.yaml"
|
||||
|
||||
|
||||
def load_models_config(path: Path | None = None) -> list[ModelConfig]:
|
||||
"""从 YAML 文件加载模型配置。如果未指定路径,使用配置中的默认路径。"""
|
||||
if path is None:
|
||||
path = get_models_config_path()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Models config not found: {path}")
|
||||
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
parsed = _ModelsFile.model_validate(raw)
|
||||
return parsed.models
|
||||
|
||||
|
||||
def load_providers_config(path: Path | None = None) -> dict[str, ProviderConfig]:
|
||||
"""从 YAML 文件加载提供商配置。如果未指定路径,使用配置中的默认路径。"""
|
||||
if path is None:
|
||||
path = get_providers_config_path()
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Providers config not found: {path}")
|
||||
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
# 验证前将字典键注入为 provider 的 name 字段
|
||||
providers_raw = raw.get("providers", {})
|
||||
for name, cfg in providers_raw.items():
|
||||
if isinstance(cfg, dict) and "name" not in cfg:
|
||||
cfg["name"] = name
|
||||
parsed = _ProvidersFile.model_validate(raw)
|
||||
return parsed.providers
|
||||
|
||||
|
||||
def get_pricing_config_path() -> Path:
|
||||
"""返回 pricing.yaml 路径(project_root/config/pricing.yaml)。"""
|
||||
return settings.project_root / "config" / "pricing.yaml"
|
||||
|
||||
|
||||
def load_pricing_config(path: Path | None = None) -> dict[str, dict]:
|
||||
"""从 YAML 文件加载定价配置。"""
|
||||
if path is None:
|
||||
path = get_pricing_config_path()
|
||||
if not path.exists():
|
||||
return {}
|
||||
raw: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
parsed = _PricingFile.model_validate(raw)
|
||||
return parsed.pricing
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ( # noqa: TC002 — Pydantic 模型
|
||||
BaseModel,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class SupportedFormats(StrEnum):
|
||||
OPENAI = "openai"
|
||||
ANTHROPIC = "anthropic"
|
||||
|
||||
|
||||
_BASE_CAPABILITIES = {
|
||||
("openai", "gpt"): {"chat", "tool_use", "structured_output"},
|
||||
("openai", "gpt-4"): {"chat", "tool_use", "structured_output", "vision", "reasoning"},
|
||||
("anthropic", "claude"): {
|
||||
"chat",
|
||||
"tool_use",
|
||||
"structured_output",
|
||||
"vision",
|
||||
"reasoning",
|
||||
"long_context",
|
||||
},
|
||||
("deepseek", "deepseek"): {"chat", "tool_use", "structured_output", "reasoning"},
|
||||
("deepseek", "deepseek-v4"): {
|
||||
"chat",
|
||||
"tool_use",
|
||||
"structured_output",
|
||||
"reasoning",
|
||||
"long_context",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def derive_capabilities(provider: str, model: str) -> set[str]:
|
||||
"""根据 provider + model 前缀自动推导能力。
|
||||
|
||||
调用者可以在调用此函数后,在 ModelConfig 上覆盖个别标记。
|
||||
"""
|
||||
for (prov_prefix, model_prefix), caps in _BASE_CAPABILITIES.items():
|
||||
if provider.startswith(prov_prefix) and model.startswith(model_prefix):
|
||||
return set(caps)
|
||||
return {"chat"}
|
||||
|
||||
|
||||
class ModelEndpointConfig(BaseModel):
|
||||
name: str
|
||||
model: str
|
||||
base_url: str | None = None
|
||||
base_url_env: str | None = None
|
||||
api_key: str | None = None
|
||||
api_key_env: str | None = None
|
||||
weight: int = 100
|
||||
priority: int = 100
|
||||
rpm_limit: int | None = None
|
||||
tpm_limit: int | None = None
|
||||
max_concurrency: int | None = None
|
||||
request_timeout_seconds: int | None = None
|
||||
status: str = "active"
|
||||
extra_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
name: str
|
||||
display_name: str | None = None
|
||||
provider: str
|
||||
model: str
|
||||
format: SupportedFormats # "openai" | "anthropic"
|
||||
chat_format: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
supports_tools: bool | None = None
|
||||
supports_vision: bool | None = None
|
||||
supports_reasoning_effort: bool | None = None
|
||||
supports_thinking: bool | None = None
|
||||
supports_streaming: bool | None = True
|
||||
supports_structured_output: bool | None = None
|
||||
context_window: int = 32000
|
||||
response_reserve: int = 4096
|
||||
max_output_tokens: int | None = None
|
||||
max_thinking_tokens: int | None = None
|
||||
reasoning_effort: str | None = None
|
||||
timeout_seconds: int | None = 60
|
||||
dimensions: int | None = None # 向量维度(embedding 模型专用)
|
||||
routing_strategy: str = "weighted_round_robin"
|
||||
endpoint_pool_enabled: bool = False
|
||||
weight: int = 100
|
||||
endpoints: list[ModelEndpointConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_embedding_dimensions(self) -> ModelConfig:
|
||||
"""embedding 模型必须声明 dimensions。"""
|
||||
if "embedding" in self.tags and self.dimensions is None:
|
||||
raise ValueError(
|
||||
f"Embedding 模型 '{self.name}' 必须设置 dimensions(如 dimensions: 1024)"
|
||||
)
|
||||
return self
|
||||
|
||||
def resolved_capabilities(self) -> dict[str, bool]:
|
||||
base = derive_capabilities(self.provider, self.model)
|
||||
return {
|
||||
"chat": True,
|
||||
"tool_use": self.supports_tools
|
||||
if self.supports_tools is not None
|
||||
else ("tool_use" in base),
|
||||
"vision": self.supports_vision
|
||||
if self.supports_vision is not None
|
||||
else ("vision" in base),
|
||||
"reasoning": self.supports_reasoning_effort
|
||||
if self.supports_reasoning_effort is not None
|
||||
else ("reasoning" in base),
|
||||
"thinking": self.supports_thinking
|
||||
if self.supports_thinking is not None
|
||||
else ("reasoning" in base),
|
||||
"streaming": self.supports_streaming if self.supports_streaming is not None else True,
|
||||
"structured_output": self.supports_structured_output
|
||||
if self.supports_structured_output is not None
|
||||
else ("structured_output" in base),
|
||||
}
|
||||
|
||||
|
||||
class ParameterMapping(BaseModel):
|
||||
provider_param: str
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
default: Any = None
|
||||
transform: str | None = None # "multiply_1000", "int", "str"
|
||||
alias_for: str | None = None # map from another param name
|
||||
handler: str | None = None # named handler for complex param transform
|
||||
|
||||
|
||||
class ProviderConfig(BaseModel):
|
||||
name: str
|
||||
format: SupportedFormats
|
||||
base_url_env: str
|
||||
api_key_env: str
|
||||
base_url: str | None = None
|
||||
timeout_seconds: int = 60
|
||||
max_retries: int = 1
|
||||
parameter_mappings: dict[str, ParameterMapping] = Field(default_factory=dict)
|
||||
parameter_handlers: list[str] = Field(
|
||||
default_factory=list
|
||||
) # ordered global handlers per provider
|
||||
extra_headers: dict[str, str] = Field(default_factory=dict)
|
||||
supports_streaming: bool = True
|
||||
|
||||
@field_validator("format")
|
||||
@classmethod
|
||||
def _validate_format(cls, v: str) -> str:
|
||||
allowed = {e.value for e in SupportedFormats}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"format must be one of {allowed}, got '{v}'")
|
||||
return v
|
||||
Reference in new issue
Block a user