Files
2026-08-21 16:00:13 +08:00

159 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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