Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 249 additions & 35 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,10 +353,165 @@ def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient:
pass
return create_proxy_client("OpenAI", proxy, httpx_module=httpx_module)

def _create_sdk_client(self, api_key: str | None):
"""Create an OpenAI SDK client for one configured key.

Args:
api_key: API key assigned to the client.

Returns:
An Azure OpenAI or OpenAI async client matching the provider config.
"""
if "api_version" in self.provider_config:
return AsyncAzureOpenAI(
api_key=api_key,
api_version=self.provider_config.get("api_version", None),
default_headers=self.custom_headers,
base_url=self.provider_config.get("api_base", ""),
timeout=self.timeout,
http_client=self._create_http_client(self.provider_config),
)
return AsyncOpenAI(
api_key=api_key,
base_url=self.provider_config.get("api_base", None),
default_headers=self.custom_headers,
timeout=self.timeout,
http_client=self._create_http_client(self.provider_config),
)

def _store_model_key_indexes(
self,
model_key_indexes: dict[str, list[int]],
) -> None:
"""Store model ownership discovered from all configured keys.

Args:
model_key_indexes: Mapping from model ID to zero-based key indexes.
"""
self._model_key_indexes = {
model: set(indexes) for model, indexes in model_key_indexes.items()
}

def get_model_key_indexes(self) -> dict[str, list[int]]:
"""Return the model ownership found by the latest model discovery.

Returns:
A copy of the model-to-key-index mapping.
"""
mapping = getattr(self, "_model_key_indexes", {})
return {model: sorted(indexes) for model, indexes in mapping.items()}

def _candidate_api_keys_for_model(self, model: str) -> list[str]:
"""Select keys known to expose a model, falling back to all keys.

Args:
model: Model ID used by the request.

Returns:
Deduplicated API keys eligible for the request.
"""
api_keys = list(dict.fromkeys(self.api_keys or [""]))
mapping = getattr(self, "_model_key_indexes", {})
indexes = mapping.get(model)
if not indexes:
return api_keys
matched = [
self.api_keys[index]
for index in sorted(indexes)
if 0 <= index < len(self.api_keys)
]
return list(dict.fromkeys(matched)) or api_keys

def _remember_model_key(self, model: str, api_key: str) -> None:
"""Remember that a request succeeded for a model and key.

Args:
model: Model ID used by the request.
api_key: API key that completed the request.
"""
if api_key not in self.api_keys:
return
mapping = getattr(self, "_model_key_indexes", None)
if mapping is None:
mapping = {}
self._model_key_indexes = mapping
mapping.setdefault(model, set()).add(self.api_keys.index(api_key))

def _forget_model_key(self, model: str, api_key: str) -> None:
"""Remove a model/key association after an access failure.

Args:
model: Model ID rejected by the provider.
api_key: API key that could not access the model.
"""
if api_key not in self.api_keys:
return
mapping = getattr(self, "_model_key_indexes", {})
indexes = mapping.get(model)
if not indexes:
return
indexes.discard(self.api_keys.index(api_key))
if not indexes:
mapping.pop(model, None)

def _key_label(self, api_key: str) -> str:
"""Return a log-safe ordinal label for an API key.

Args:
api_key: Configured API key to identify.

Returns:
A label such as ``Key #2`` without exposing key material.
"""
try:
return f"Key #{self.api_keys.index(api_key) + 1}"
except (AttributeError, ValueError):
return "configured key"

def _is_key_or_model_access_error(self, error: Exception) -> bool:
"""Check whether retrying the model with another key is appropriate.

Args:
error: Provider exception raised by a chat request.

Returns:
Whether the error indicates key authentication or model access failure.
"""
status_codes = (
getattr(error, "status_code", None),
getattr(error, "status", None),
getattr(getattr(error, "response", None), "status_code", None),
)
if any(code in {401, 403, 404} for code in status_codes):
return True
error_text = " ".join(self._extract_error_text_candidates(error)).lower()
if "model" not in error_text and "模型" not in error_text:
return False
return any(
marker in error_text
for marker in (
"does not exist",
"not found",
"not available",
"not accessible",
"no access",
"access denied",
"insufficient permission",
"permission",
"not allowed",
"unauthorized",
"不存在",
"不可用",
"无权",
"权限",
)
)

def __init__(self, provider_config, provider_settings) -> None:
super().__init__(provider_config, provider_settings)
self.chosen_api_key = None
self.api_keys: list = super().get_keys()
self._model_key_indexes: dict[str, set[int]] = {}
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None
self.timeout = provider_config.get("timeout", 120)
self.custom_headers = provider_config.get("custom_headers", {})
Expand All @@ -369,25 +524,7 @@ def __init__(self, provider_config, provider_settings) -> None:
for key in self.custom_headers:
self.custom_headers[key] = str(self.custom_headers[key])

if "api_version" in provider_config:
# Using Azure OpenAI API
self.client = AsyncAzureOpenAI(
api_key=self.chosen_api_key,
api_version=provider_config.get("api_version", None),
default_headers=self.custom_headers,
base_url=provider_config.get("api_base", ""),
timeout=self.timeout,
http_client=self._create_http_client(provider_config),
)
else:
# Using OpenAI Official API
self.client = AsyncOpenAI(
api_key=self.chosen_api_key,
base_url=provider_config.get("api_base", None),
default_headers=self.custom_headers,
timeout=self.timeout,
http_client=self._create_http_client(provider_config),
)
self.client = self._create_sdk_client(self.chosen_api_key)

self.default_params = inspect.signature(
self.client.chat.completions.create,
Expand Down Expand Up @@ -435,18 +572,68 @@ def _apply_provider_specific_request_overrides(
extra_body["reasoning_effort"] = "none"

async def get_models(self):
try:
models_str = []
models = await retry_provider_request(
"OpenAI",
lambda: self.client.models.list(),
)
models = sorted(models.data, key=lambda x: x.id)
for model in models:
models_str.append(model.id)
return models_str
except NotFoundError as e:
raise Exception(f"获取模型列表失败:{e}")
api_keys = getattr(self, "api_keys", None)
if not isinstance(api_keys, list) or len(api_keys) <= 1:
try:
models = await retry_provider_request(
"OpenAI",
lambda: self.client.models.list(),
)
models_str = sorted(
model_id
for model in models.data
if isinstance(model_id := getattr(model, "id", None), str)
and model_id
)
if isinstance(api_keys, list):
self._store_model_key_indexes({model: [0] for model in models_str})
return models_str
except NotFoundError as e:
raise Exception(f"获取模型列表失败:{e}")

unique_keys = list(dict.fromkeys(api_keys))
model_key_indexes: dict[str, list[int]] = {}
last_error: Exception | None = None
successful_requests = 0

for api_key in unique_keys:
key_index = api_keys.index(api_key)
client = None
try:
client = self._create_sdk_client(api_key)
models = await retry_provider_request(
f"OpenAI {self._key_label(api_key)}",
lambda client=client: client.models.list(),
)
successful_requests += 1
for model in models.data:
model_id = getattr(model, "id", None)
if not isinstance(model_id, str) or not model_id:
continue
model_key_indexes.setdefault(model_id, []).append(key_index)
except Exception as exc:
last_error = exc
logger.warning(
"Failed to fetch the OpenAI model list with %s: %s",
self._key_label(api_key),
type(exc).__name__,
)
finally:
if client is not None:
try:
await client.close()
except Exception as exc:
logger.debug(
"Failed to close the temporary OpenAI client for %s: %s",
self._key_label(api_key),
type(exc).__name__,
)

if successful_requests == 0 and last_error is not None:
raise last_error

self._store_model_key_indexes(model_key_indexes)
return sorted(model_key_indexes.keys())

@staticmethod
def _sanitize_assistant_messages(payloads: dict) -> None:
Expand Down Expand Up @@ -1167,6 +1354,29 @@ async def _handle_api_error(
)
# logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")

if self._is_key_or_model_access_error(e) and len(self.api_keys) > 1:
model_id = str(payloads.get("model") or self.get_model())
self._forget_model_key(model_id, chosen_key)
if chosen_key in available_api_keys:
available_api_keys.remove(chosen_key)
if available_api_keys:
next_key = random.choice(available_api_keys)
logger.warning(
"%s cannot access model %s; retrying with %s.",
self._key_label(chosen_key),
model_id,
self._key_label(next_key),
)
return (
False,
next_key,
available_api_keys,
payloads,
context_query,
func_tool,
image_fallback_used,
)

if is_connection_error(e):
proxy = self.provider_config.get("proxy", "")
log_connection_failure("OpenAI", e, proxy)
Expand Down Expand Up @@ -1204,8 +1414,9 @@ async def text_chat(
payloads["tool_choice"] = tool_choice

llm_response = None
max_retries = 10
available_api_keys = self.api_keys.copy()
model_id = str(payloads.get("model") or self.get_model())
available_api_keys = self._candidate_api_keys_for_model(model_id)
max_retries = max(10, len(available_api_keys) + 2)
chosen_key = random.choice(available_api_keys)
image_fallback_used = False

Expand All @@ -1219,6 +1430,7 @@ async def text_chat(
func_tool,
request_max_retries=request_max_retries,
)
self._remember_model_key(model_id, chosen_key)
break
except Exception as e:
last_exception = e
Expand Down Expand Up @@ -1280,8 +1492,9 @@ async def text_chat_stream(
if func_tool and not func_tool.empty():
payloads["tool_choice"] = tool_choice

max_retries = 10
available_api_keys = self.api_keys.copy()
model_id = str(payloads.get("model") or self.get_model())
available_api_keys = self._candidate_api_keys_for_model(model_id)
max_retries = max(10, len(available_api_keys) + 2)
chosen_key = random.choice(available_api_keys)
image_fallback_used = False

Expand All @@ -1296,6 +1509,7 @@ async def text_chat_stream(
request_max_retries=request_max_retries,
):
yield response
self._remember_model_key(model_id, chosen_key)
break
except Exception as e:
last_exception = e
Expand Down
7 changes: 6 additions & 1 deletion astrbot/dashboard/services/config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1428,7 +1428,7 @@ async def list_provider_source_models(self, source_id: str) -> dict:
try:
models = await inst.get_models()
models = models or []
return {
result = {
"models": models,
"provider_source_id": source_id,
"model_metadata": {
Expand All @@ -1437,6 +1437,11 @@ async def list_provider_source_models(self, source_id: str) -> dict:
if model_id in LLM_METADATAS
},
}
key_mapping_getter = getattr(inst, "get_model_key_indexes", None)
if callable(key_mapping_getter):
model_key_indexes = key_mapping_getter()
result["model_key_indexes"] = model_key_indexes
return result
finally:
terminate_fn = getattr(inst, "terminate", None)
if callable(terminate_fn):
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/api/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface ProviderByIdData {
export interface ProviderSourceModelsData {
models?: string[];
model_metadata?: Record<string, unknown>;
model_key_indexes?: Record<string, number[]>;
}

export interface ProviderTestData {
Expand Down
Loading
Loading