Files
MoviePilot/app/agent/llm/discovery.py
Aqr-K 7584d4b9fc fix: isolate agent sessions and retain runtime resource ownership (#7149)
* fix: arbitrate subscription identity and restore workflow state

* fix: isolate agent sessions and retain runtime resource ownership

* test: preserve legacy empty-owner subscription transactions

* test: handle reaped terminal descendants during shutdown polling

* fix: align shared atomic configuration method with lifecycle prerequisite

* test: cover subagent cancellation and completion lifecycle boundaries

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:20:59 +08:00

1035 lines
39 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.
"""
LLM Provider 远端目录发现和 SDK 客户端 I/O 的唯一 owner。
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlsplit
from app.adapters.network.http import AsyncRequestUtils
from app.agent.llm.runtime import LLMProviderAuthError, LLMProviderError
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
def attach_server_tool_capabilities(
provider: str,
models: list[dict[str, Any]],
base_url: Optional[str] = None,
) -> list[dict[str, Any]]:
"""
为 Provider 模型目录附加统一的服务端工具能力描述。
"""
from app.agent.llm.tools import ServerToolRegistry
result = []
for item in models:
model_item = dict(item)
model_item["server_tools"] = ServerToolRegistry.list_capabilities(
provider=provider,
model=str(model_item.get("id") or ""),
base_url=base_url,
)
result.append(model_item)
return result
class _ProviderDiscovery:
"""
LLM Provider 远端目录发现和 SDK 客户端 I/O 的唯一 owner。
"""
_models_dev_lock: Any
_models_dev_data: Optional[dict[str, Any]]
_models_dev_loaded_at: float
_models_dev_cache_path: Path
def __getattr__(self, name: str) -> Any:
"""
将跨 owner 调用交给最终 Facade 的 MRO 解析。
"""
raise AttributeError(name)
_MODELS_DEV_URL = "https://models.dev/api.json"
_MODELS_DEV_BUNDLED_PATH = Path(__file__).with_name("resources") / "models.json"
_MODELS_DEV_CACHE_TTL = 7 * 24 * 60 * 60
_BEDROCK_DEFAULT_REGION = "us-east-1"
_BEDROCK_API_KEY_PREFIX = "bedrock-api-key-"
_BEDROCK_GPT_OSS_BASE_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-north-1",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_GPT_OSS_SAFEGUARD_REGIONS = (
"ap-northeast-1",
"ap-south-1",
"ap-southeast-2",
"eu-west-1",
"eu-west-2",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2",
)
_BEDROCK_ON_DEMAND_MODEL_REGIONS = {
"openai.gpt-oss-120b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-20b-1:0": _BEDROCK_GPT_OSS_BASE_REGIONS,
"openai.gpt-oss-safeguard-120b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"openai.gpt-oss-safeguard-20b": _BEDROCK_GPT_OSS_SAFEGUARD_REGIONS,
"amazon.nova-lite-v1:0": (
"ap-northeast-1",
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-micro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"amazon.nova-pro-v1:0": (
"ap-southeast-2",
"eu-west-2",
"us-east-1",
"us-gov-west-1",
),
"anthropic.claude-3-5-haiku-20241022-v1:0": ("us-west-2",),
"anthropic.claude-3-5-sonnet-20240620-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-southeast-1",
"eu-central-1",
"eu-central-2",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
"anthropic.claude-3-5-sonnet-20241022-v2:0": (
"ap-southeast-2",
"us-west-2",
),
"anthropic.claude-3-7-sonnet-20250219-v1:0": (
"eu-west-2",
"us-gov-west-1",
),
"anthropic.claude-3-haiku-20240307-v1:0": (
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
"ap-southeast-2",
"eu-central-1",
"eu-west-1",
"eu-west-3",
"us-east-1",
"us-gov-west-1",
"us-west-2",
),
}
_BEDROCK_GEO_PREFIXES: dict[str, tuple[str, ...]] = {
"us": ("us-east-", "us-west-"),
"eu": ("eu-",),
"apac": ("ap-",),
"au": ("ap-southeast-2", "ap-southeast-4"),
"jp": ("ap-northeast-1", "ap-northeast-3"),
"ca": ("ca-",),
}
_BEDROCK_NON_COMMERCIAL_REGION_PREFIXES = (
"cn-",
"eu-isoe-",
"us-gov-",
"us-iso-",
"us-isob-",
"us-isof-",
)
def _build_async_request(
self,
use_proxy: Optional[bool] = None,
headers: Optional[dict[str, str]] = None,
) -> AsyncRequestUtils:
"""
按 LLM 代理策略构造统一异步 HTTP 请求客户端。
"""
should_use_proxy = get_runtime_setting("LLM_USE_PROXY") if use_proxy is None else use_proxy
proxy = get_runtime_setting("PROXY_HOST") if should_use_proxy else None
proxies = {"http": proxy, "https": proxy} if proxy else None
return AsyncRequestUtils(
headers=headers,
proxies=proxies,
timeout=15,
verify=True,
trust_env=False,
)
def _build_sdk_http_client(self, use_proxy: Optional[bool] = None) -> Any:
"""
为需要注入 transport 的第三方 SDK 构造统一异步客户端。
"""
should_use_proxy = get_runtime_setting("LLM_USE_PROXY") if use_proxy is None else use_proxy
proxy = get_runtime_setting("PROXY_HOST") if should_use_proxy else None
return AsyncRequestUtils.create_sdk_client(
proxy=proxy,
timeout=15,
connect_timeout=10,
trust_env=False,
)
async def _load_models_dev_from_disk(self) -> dict[str, Any] | None:
"""
从磁盘缓存加载 models.dev 数据。
"""
try:
if not self._models_dev_cache_path.exists():
return None
content = await asyncio.to_thread(
self._models_dev_cache_path.read_text,
encoding="utf-8",
errors="replace",
)
payload = json.loads(content)
return payload if isinstance(payload, dict) else None
except Exception as err:
logger.warning(f"读取 models.dev 缓存失败: {err}")
return None
def _load_bundled_models_dev_payload(self) -> dict[str, Any] | None:
"""
从随代码附带的离线文件加载 models.dev 数据。
"""
try:
if not self._MODELS_DEV_BUNDLED_PATH.exists():
return None
payload = json.loads(self._MODELS_DEV_BUNDLED_PATH.read_text(encoding="utf-8", errors="replace"))
except Exception as err:
logger.warning(f"读取本地 models.dev 离线文件失败: {err}")
return None
return payload if isinstance(payload, dict) else None
async def _write_models_dev_to_disk(self, payload: dict[str, Any]) -> None:
"""
将 models.dev 数据写入磁盘缓存。
"""
try:
self._models_dev_cache_path.parent.mkdir(parents=True, exist_ok=True)
content = json.dumps(payload, ensure_ascii=False)
await asyncio.to_thread(
self._models_dev_cache_path.write_text,
content,
encoding="utf-8",
)
except Exception as err:
logger.warning(f"写入 models.dev 缓存失败: {err}")
async def _fetch_models_dev(self, use_proxy: Optional[bool] = None) -> dict[str, Any]:
"""
通过网络请求获取最新 models.dev 数据。
"""
response = await self._build_async_request(
use_proxy,
headers={"User-Agent": get_runtime_setting("USER_AGENT")},
).get_res(self._MODELS_DEV_URL, raise_exception=True)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise LLMProviderError("models.dev 返回了无效目录响应")
return payload
async def get_models_dev_data(
self,
force_refresh: bool = False,
use_proxy: Optional[bool] = None,
) -> dict[str, Any]:
"""
返回 models.dev 原始数据。
这里复用 opencode 的做法,把公共模型目录缓存到本地文件中,避免每次
刷新模型列表都直接打到远端。
"""
async with self._models_dev_lock:
now = time.time()
if (
not force_refresh
and self._models_dev_data is not None
and now - self._models_dev_loaded_at < self._MODELS_DEV_CACHE_TTL
):
return self._models_dev_data
if not force_refresh and self._models_dev_cache_path.exists():
mtime = self._models_dev_cache_path.stat().st_mtime
if now - mtime < self._MODELS_DEV_CACHE_TTL:
cached = await self._load_models_dev_from_disk()
if isinstance(cached, dict):
self._models_dev_data = cached
self._models_dev_loaded_at = now
return cached
try:
payload = await self._fetch_models_dev(use_proxy=use_proxy)
self._models_dev_data = payload
self._models_dev_loaded_at = now
await self._write_models_dev_to_disk(payload)
return payload
except Exception as err:
logger.warning(f"刷新 models.dev 失败,尝试回退本地缓存: {err}")
cached = await self._load_models_dev_from_disk()
if isinstance(cached, dict):
self._models_dev_data = cached
self._models_dev_loaded_at = now
return cached
bundled = self._load_bundled_models_dev_payload()
if isinstance(bundled, dict):
self._models_dev_data = bundled
self._models_dev_loaded_at = now
return bundled
raise LLMProviderError(f"获取 models.dev 数据失败: {err}") from err
def _normalize_base_url_for_anthropic(self, base_url: str) -> str:
"""
对 Anthropic 的 Base URL 进行适配处理。
"""
normalized = self._sanitize_base_url(base_url) or ""
if normalized.endswith("/v1"):
return normalized[:-3]
return normalized
@classmethod
def _extract_bedrock_region(cls, base_url: Optional[str]) -> str:
"""
从 Bedrock 运行时端点 URL 中提取 AWS Region
兼容标准端点、FIPS 端点与 PrivateLink(VPCE)端点等主机名形态,
从中识别 Region 段。
:param base_url: 形如 https://bedrock-runtime.us-east-1.amazonaws.com 的端点地址
:return: 提取到的 Region,无法识别时回退 us-east-1
"""
hostname = urlsplit((base_url or "").strip().lower()).hostname or ""
match = re.search(
r"(?:^|\.)(?:bedrock(?:-runtime)?(?:-fips)?)"
r"\.([a-z0-9-]+-\d+)(?:\.|$)",
hostname,
)
if match:
return match.group(1)
return cls._BEDROCK_DEFAULT_REGION
@classmethod
def _bedrock_model_matches_region(cls, model_id: str, region: str) -> bool:
"""
判断目录中的模型 ID 在指定 Region 是否可调用
models.dev 目录同时收录裸模型 ID(直连调用)与带地理前缀的
Inference Profile ID(us./eu./apac./global. 等)。带前缀的条目只在
对应地理分区和 AWS 分区的 Region 可用;global Profile 仅允许商业
AWS 分区。裸 ID 仅在明确记录的 ON_DEMAND Region 可用,未知条目
按不可直连处理。
:param model_id: 目录中的模型 ID
:param region: 当前 Base URL 对应的 AWS Region
:return: 该模型在当前 Region 可调用时返回 True
"""
prefix = model_id.split(".", 1)[0]
if prefix == "global":
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES)
region_prefixes = cls._BEDROCK_GEO_PREFIXES.get(prefix)
if region_prefixes is not None:
return not region.startswith(cls._BEDROCK_NON_COMMERCIAL_REGION_PREFIXES) and region.startswith(
region_prefixes
)
on_demand_regions = cls._BEDROCK_ON_DEMAND_MODEL_REGIONS.get(model_id)
return on_demand_regions is not None and region in on_demand_regions
@classmethod
def _parse_bedrock_credentials(cls, api_key: Optional[str]) -> dict[str, Any]:
"""
解析 Bedrock 凭证字符串,识别 Bearer 与 SigV4 两种认证方式
- Bedrock API Key(bedrock-api-key- 开头的长期 Key,或控制台生成的短期
Token)走 Bearer 认证;
- `AccessKeyId:SecretAccessKey` 或 `AccessKeyId:SecretAccessKey:SessionToken`
走 SigV4 认证,AWS Access Key ID 均以 "AKIA"/"ASIA" 开头。
:param api_key: 用户在 API Key 输入框填写的凭证内容
:return: 含 auth_scheme 及对应凭证字段的字典
"""
normalized = str(api_key or "").strip()
if not normalized:
raise LLMProviderAuthError("Amazon Bedrock 需要填写 Bedrock API Key 或 Access Key ID:Secret Access Key")
if not normalized.startswith(cls._BEDROCK_API_KEY_PREFIX):
parts = [part.strip() for part in normalized.split(":")]
if len(parts) in {2, 3} and all(parts):
credentials = {
"auth_scheme": "sigv4",
"access_key_id": parts[0],
"secret_access_key": parts[1],
}
if len(parts) == 3:
credentials["session_token"] = parts[2]
return credentials
if ":" in normalized:
raise LLMProviderAuthError(
"Amazon Bedrock AK/SK 凭证格式不正确,"
"请按 AccessKeyId:SecretAccessKey 或 "
"AccessKeyId:SecretAccessKey:SessionToken 填写"
)
return {"auth_scheme": "bearer", "bearer_token": normalized}
async def _list_models_from_google(
self,
api_key: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 Google AI Studio 获取模型列表。
"""
from google import genai
from google.genai.types import HttpOptions
should_use_proxy = get_runtime_setting("LLM_USE_PROXY") if use_proxy is None else use_proxy
proxy = get_runtime_setting("PROXY_HOST") if should_use_proxy else None
client_args = AsyncRequestUtils.build_sdk_client_args(
proxy=proxy,
timeout=15,
connect_timeout=10,
trust_env=False,
)
# google-genai 的 aiohttp 请求已经显式传入 timeout,不能再从
# async_client_args 透传同名参数,否则会触发重复关键字异常。
async_client_args = {
key: value for key, value in client_args.items() if key != "timeout"
}
http_options = HttpOptions(
timeout=15_000,
client_args=client_args,
async_client_args=async_client_args,
)
client = genai.Client(api_key=api_key, http_options=http_options)
try:
response = await client.aio.models.list()
results = []
tokens: set[str] = set()
for _page in range(100):
for model in response.page:
supported = set(model.supported_actions or [])
if "generateContent" not in supported:
continue
model_id = model.name
metadata = (
await self._models_dev_model(
"google",
model_id,
use_proxy=use_proxy,
)
or {}
)
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=model.display_name or metadata.get("name") or model_id,
metadata=metadata,
source="provider",
)
)
token = response.config.get("page_token")
if not token:
break
if token in tokens:
raise LLMProviderError("Google 模型列表分页游标重复")
tokens.add(token)
await response.next_page()
else:
raise LLMProviderError("Google 模型列表分页超过上限")
return sorted(results, key=lambda item: item["name"].lower())
finally:
try:
await client.aio.aclose()
finally:
client.close()
async def _list_models_from_openai_compatible(
self,
provider_id: str,
api_key: str,
base_url: str,
default_headers: Optional[dict[str, str]] = None,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
通过 OpenAI 兼容接口获取模型列表。
"""
from openai import AsyncOpenAI
async with AsyncOpenAI(
api_key=api_key,
base_url=base_url,
default_headers=default_headers,
timeout=15.0,
max_retries=2,
http_client=self._build_sdk_http_client(use_proxy),
) as client:
results = []
response = await client.models.list()
for model in response.data:
metadata = (
await self._models_dev_model(
provider_id,
model.id,
base_url=base_url,
use_proxy=use_proxy,
)
or {}
)
results.append(
self._normalize_model_record(
model_id=model.id,
display_name=metadata.get("name") or model.id,
metadata=metadata,
source="provider",
)
)
return sorted(results, key=lambda item: item["name"].lower())
async def _list_models_from_models_dev_only(
self,
provider_id: str,
transport: str = "openai",
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
某些 provider 没有统一稳定的 models.list 行为,
因此优先读取 models.dev 目录;若未来 provider 暴露标准 models 接口,
再平滑补充实时刷新即可。
"""
payload = await self._models_dev_provider_payload(
provider_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict):
raise LLMProviderError(f"{provider_id} 暂无可用模型目录")
results = []
for model_id, metadata in models.items():
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=metadata.get("name") or model_id,
metadata=metadata,
transport=transport,
source="models.dev",
)
)
return sorted(results, key=lambda item: item["name"].lower())
def _build_bedrock_boto3_config(
self,
use_proxy: Optional[bool] = None,
) -> Any:
"""
构造 Bedrock boto3 客户端配置,统一超时、重试与代理策略
:param use_proxy: 是否使用系统代理,None 时读取 LLM_USE_PROXY 配置
:return: botocore Config 实例
"""
from botocore.config import Config
should_use_proxy = get_runtime_setting("LLM_USE_PROXY") if use_proxy is None else use_proxy
proxies = None
if should_use_proxy and get_runtime_setting("PROXY_HOST"):
proxies = {"http": get_runtime_setting("PROXY_HOST"), "https": get_runtime_setting("PROXY_HOST")}
return Config(
connect_timeout=10,
read_timeout=60,
retries={"max_attempts": 3, "mode": "standard"},
proxies=proxies,
)
@staticmethod
def _bedrock_endpoint_url(service_name: str, base_url: Optional[str]) -> Optional[str]:
"""
解析应传给 boto3 客户端的自定义端点 URL
标准公有端点交由 boto3 按 Region 自行推导;用户填写 PrivateLink、
FIPS 等非标准端点时才显式透传,保证所选网络路径实际生效。
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime)
:param base_url: 用户配置的 Base URL
:return: 需要显式指定端点时返回 URL,否则返回 None
"""
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
return None
if re.fullmatch(
rf"https://{service_name}\.[a-z0-9-]+\.amazonaws\.com",
normalized,
):
return None
return normalized
def create_bedrock_client(
self,
service_name: str,
region: str,
credentials: dict[str, Any],
base_url: Optional[str] = None,
use_proxy: Optional[bool] = None,
read_timeout: Optional[int] = None,
) -> Any:
"""
按解析后的凭证创建 Bedrock boto3 客户端,Bearer 方式注入 Authorization 头
:param service_name: boto3 服务名(bedrock 或 bedrock-runtime)
:param region: AWS 服务区域标识,如 us-east-1
:param credentials: `_parse_bedrock_credentials` 的解析结果
:param base_url: 用户配置的 Base URL,非标准端点(PrivateLink/FIPS 等)时透传给 boto3
:param use_proxy: 是否使用系统代理
:param read_timeout: 读取超时秒数,None 时使用默认值
:return: boto3 客户端实例
"""
import boto3
from botocore import UNSIGNED
config = self._build_bedrock_boto3_config(use_proxy)
if read_timeout:
config = config.merge(type(config)(read_timeout=read_timeout))
endpoint_kwargs: dict[str, Any] = {}
endpoint_url = self._bedrock_endpoint_url(service_name, base_url)
if endpoint_url:
endpoint_kwargs["endpoint_url"] = endpoint_url
if credentials["auth_scheme"] == "sigv4":
return boto3.client(
service_name,
region_name=region,
aws_access_key_id=credentials["access_key_id"],
aws_secret_access_key=credentials["secret_access_key"],
aws_session_token=credentials.get("session_token"),
config=config,
**endpoint_kwargs,
)
# Bearer 认证:以 UNSIGNED 跳过 SigV4 签名,再把 API Key 注入 Authorization 头。
bearer_token = credentials["bearer_token"]
config = config.merge(type(config)(signature_version=UNSIGNED))
client = boto3.client(
service_name,
region_name=region,
aws_access_key_id="unsigned",
aws_secret_access_key="unsigned",
config=config,
**endpoint_kwargs,
)
def _inject_bearer(request: Any, **_kwargs: Any) -> None:
request.headers["Authorization"] = f"Bearer {bearer_token}"
client.meta.events.register(
f"request-created.{service_name}",
_inject_bearer,
)
return client
async def _list_models_from_bedrock_fallback(
self,
region: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 models.dev 目录筛选当前 Region 可调用的 Bedrock 模型
:param region: 当前 Base URL 对应的 AWS Region
:param use_proxy: 是否使用系统代理
:return: 过滤后的标准化模型记录列表
"""
models = await self._list_models_from_models_dev_only(
provider_id="amazon-bedrock",
use_proxy=use_proxy,
)
return [model for model in models if self._bedrock_model_matches_region(model["id"], region)]
async def _list_models_from_bedrock(
self,
api_key: str,
base_url: Optional[str],
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 Bedrock 控制面拉取模型目录,聚合跨区 Inference Profile 与直连模型
Bedrock 多数新模型仅允许通过 Inference Profile(us./eu./apac./global. 前缀)
调用,因此优先列出 Profile,再补充支持 ON_DEMAND 直连的基础模型。
:param api_key: 用户填写的凭证内容(Bedrock API Key 或 AK/SK)
:param base_url: Bedrock 运行时端点,决定 Region
:param use_proxy: 是否使用系统代理
:return: 标准化后的模型记录列表
"""
credentials = self._parse_bedrock_credentials(api_key)
region = self._extract_bedrock_region(base_url)
# runtime VPCE 无法安全推导对应的控制面 VPCE;FIPS 端点也不能绕回
# 公有非 FIPS 控制面,因此直接使用本地目录。
if self._bedrock_endpoint_url("bedrock-runtime", base_url):
return await self._list_models_from_bedrock_fallback(region, use_proxy)
client = self.create_bedrock_client(
"bedrock",
region=region,
credentials=credentials,
use_proxy=use_proxy,
)
def _fetch() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
profiles: list[dict[str, Any]] = []
paginator = client.get_paginator("list_inference_profiles")
for page in paginator.paginate(typeEquals="SYSTEM_DEFINED"):
profiles.extend(page.get("inferenceProfileSummaries") or [])
foundation = (
client.list_foundation_models(
byOutputModality="TEXT",
byInferenceType="ON_DEMAND",
).get("modelSummaries")
or []
)
return profiles, foundation
try:
profile_summaries, foundation_summaries = await asyncio.to_thread(_fetch)
except Exception as err:
# 部分 Bedrock API Key 的授权范围仅覆盖 bedrock-runtime 推理接口,
# 控制面查询被拒时降级到 models.dev 目录,保证仍能选择模型。
logger.warning(f"获取 Amazon Bedrock 控制面模型列表失败,降级 models.dev 目录: {err}")
return await self._list_models_from_bedrock_fallback(region, use_proxy)
finally:
await asyncio.to_thread(client.close)
results: list[dict[str, Any]] = []
seen_ids: set[str] = set()
def _append_record(model_id: str, display_name: Optional[str]) -> None:
if not model_id or model_id in seen_ids:
return
seen_ids.add(model_id)
# Inference Profile 带区域前缀,models.dev 目录按基础模型 ID 收录,
# 去掉首个前缀段再查一次元数据。
metadata = self._cached_models_dev_model("amazon-bedrock", model_id)
if not metadata and "." in model_id:
metadata = self._cached_models_dev_model(
"amazon-bedrock",
model_id.split(".", 1)[1],
)
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=display_name or (metadata or {}).get("name") or model_id,
metadata=metadata or {},
source="provider",
)
)
for profile in profile_summaries:
if (profile.get("status") or "ACTIVE") != "ACTIVE":
continue
_append_record(
str(profile.get("inferenceProfileId") or "").strip(),
profile.get("inferenceProfileName"),
)
# 控制面已按当前 Region 和 ON_DEMAND 筛选,不能复用仅面向
# models.dev 降级目录的静态白名单,否则 AWS 新增模型会被遗漏。
for summary in foundation_summaries:
lifecycle = (summary.get("modelLifecycle") or {}).get("status") or "ACTIVE"
if lifecycle != "ACTIVE":
continue
_append_record(
str(summary.get("modelId") or "").strip(),
summary.get("modelName"),
)
return sorted(results, key=lambda item: item["name"].lower())
@staticmethod
def _copilot_headers(token: Optional[str] = None, include_auth: bool = True) -> dict[str, str]:
"""
构造 GitHub Copilot 请求头。
OpenAI-compatible 调用会由 SDK 自行补 Authorization,因此这里允许
仅补充 Copilot 必需的意图头,避免重复覆盖。
"""
headers = {
"User-Agent": get_runtime_setting("USER_AGENT"),
"Openai-Intent": "conversation-edits",
"x-initiator": "user",
}
if include_auth and token:
headers["Authorization"] = f"Bearer {token}"
return headers
async def _list_models_from_copilot(
self,
token: str,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
从 GitHub Copilot 端点获取模型列表。
"""
response = await self._build_async_request(
use_proxy,
headers=self._copilot_headers(token),
).get_res(
"https://api.githubcopilot.com/models",
raise_exception=True,
)
response.raise_for_status()
payload = response.json()
raw_models = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(raw_models, list):
raise LLMProviderError("GitHub Copilot 模型列表响应格式不正确")
results = []
for item in raw_models:
if not isinstance(item, dict):
continue
if not item.get("model_picker_enabled", True):
continue
if (item.get("policy") or {}).get("state") == "disabled":
continue
model_id = str(item.get("id") or "").strip()
if not model_id:
continue
endpoints = set(item.get("supported_endpoints") or [])
# 优先兼容 OpenAI 风格端点;仅在缺失时再切到 Anthropic 风格消息接口。
transport = (
"anthropic"
if "/v1/messages" in endpoints
and "/v1/chat/completions" not in endpoints
and "/v1/responses" not in endpoints
else "openai"
)
limits = (item.get("capabilities") or {}).get("limits") or {}
supports = (item.get("capabilities") or {}).get("supports") or {}
metadata = (
await self._models_dev_model(
"github-copilot",
model_id,
use_proxy=use_proxy,
)
or {}
)
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=item.get("name") or metadata.get("name") or model_id,
metadata=metadata,
transport=transport,
live_context=limits.get("max_context_window_tokens"),
live_input=limits.get("max_prompt_tokens"),
live_output=limits.get("max_output_tokens"),
live_supports_tools=supports.get("tool_calls"),
live_supports_reasoning=bool(
supports.get("adaptive_thinking")
or supports.get("reasoning_effort")
or supports.get("max_thinking_budget") is not None
or supports.get("min_thinking_budget") is not None
),
live_supports_image=bool(
supports.get("vision") or ((limits.get("vision") or {}).get("supported_media_types"))
),
source="provider",
)
)
return sorted(results, key=lambda i: i["name"].lower())
async def _list_chatgpt_oauth_models(
self,
provider_id: str,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
use_proxy: Optional[bool] = None,
) -> list[dict[str, Any]]:
"""
获取开启 OAuth 的 ChatGPT 模型列表。
"""
# ChatGPT OAuth 仍然是 chatgpt provider 专属能力,但模型目录不再维护
# 一份内部名单,直接跟随当前 provider 对应的 models.dev 数据。
payload = await self._models_dev_provider_payload(
provider_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, dict):
return []
results = []
for model_id, metadata in models.items():
results.append(
self._normalize_model_record(
model_id=model_id,
display_name=metadata.get("name") or model_id,
metadata=metadata,
source="models.dev",
)
)
return sorted(results, key=lambda item: item["name"].lower())
async def list_models(
self,
provider_id: str,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
base_url_preset_id: Optional[str] = None,
user_agent: Optional[str] = None,
use_proxy: Optional[bool] = None,
force_refresh: bool = False,
) -> list[dict[str, Any]]:
"""
返回标准化后的模型目录。
"""
spec = await self._get_provider_async(
provider_id,
force_refresh=force_refresh,
use_proxy=use_proxy,
)
resolved_model_list_strategy = self._resolve_provider_model_list_strategy(
spec,
base_url,
base_url_preset_id=base_url_preset_id,
)
if self._resolve_provider_models_dev_provider_id(
spec,
base_url,
base_url_preset_id=base_url_preset_id,
):
# 对依赖 models.dev 的 provider 主动刷新一次缓存,保证“刷新模型列表”
# 在使用目录型 provider 时也能拿到最新参数。
if force_refresh:
await self.get_models_dev_data(
force_refresh=True,
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "manual":
# 万擎等推理点型平台没有稳定的全局模型目录,模型 ID 需要用户从控制台复制。
return []
runtime = await self.resolve_runtime(
provider_id,
model=None,
api_key=api_key,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
user_agent=user_agent,
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "google":
return await self._list_models_from_google(
runtime["api_key"],
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "github_copilot":
return await self._list_models_from_copilot(
runtime["api_key"],
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "chatgpt":
if runtime.get("auth_mode") == "oauth":
return await self._list_chatgpt_oauth_models(
provider_id=provider_id,
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
return await self._list_models_from_openai_compatible(
provider_id="chatgpt",
api_key=runtime["api_key"],
base_url=self._resolve_provider_model_list_base_url(
spec,
runtime["base_url"],
base_url_preset_id=base_url_preset_id,
),
default_headers=self._merge_user_agent_header(
runtime.get("default_headers"),
user_agent,
),
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "bedrock":
return await self._list_models_from_bedrock(
api_key=runtime["api_key"],
base_url=runtime.get("base_url"),
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "anthropic_compatible":
return await self._list_models_from_models_dev_only(
provider_id=provider_id,
transport="anthropic",
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
if resolved_model_list_strategy == "models_dev_only":
return await self._list_models_from_models_dev_only(
provider_id=provider_id,
transport="openai",
base_url=base_url,
base_url_preset_id=base_url_preset_id,
use_proxy=use_proxy,
)
# openai-compatible / deepseek 默认走官方 models 端点。
return await self._list_models_from_openai_compatible(
provider_id=provider_id,
api_key=runtime["api_key"],
base_url=self._resolve_provider_model_list_base_url(
spec,
runtime["base_url"],
base_url_preset_id=base_url_preset_id,
),
default_headers=self._merge_user_agent_header(
runtime.get("default_headers"),
user_agent,
),
use_proxy=use_proxy,
)