项目文件夹

文件
wehub-resource-sync 2114ccd278
CI / Lint & Test (Python 3.14) (push) Failing after 1s
CI / Lint & Test (Python 3.12) (push) Failing after 2s
CI / Lint & Test (Python 3.13) (push) Failing after 2s
CI / DCO Check (push) Has been skipped
Scorecard supply-chain security / Scorecard analysis (push) Failing after 2s
chore: import upstream snapshot with attribution
2026-07-13 12:23:39 +08:00

620 行
22 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""API Key Pool — multi-key load-balancer with per-key concurrency slots.
Each key has a configurable number of concurrent slots (default 5). The pool
distributes requests across keys using least-loaded scheduling — it *never*
blocks unless every non-rate-limited key is at capacity. A single key can
serve multiple callers simultaneously; rate-limit (HTTP 429) is the only
signal that removes a key from rotation.
Contrast with the previous mutex-per-key design where :meth:`acquire` blocked
as soon as every key had *one* active request, coupling worker count to key
count. In the new design, throughput scales with workers independently of
how many keys are configured — keys just need enough aggregate slots.
Integration point
-----------------
Wrap a LangChain ``BaseChatModel`` with :class:`PooledChatModel` to give
it transparent access to the key pool. The wrapper is API-compatible with
the models returned by :func:`skillspector.llm_utils.get_chat_model` and
can be used wherever a standard ``BaseChatModel`` is expected.
Configuration
-------------
Multi-key mode (recommended for batch scans)::
export SKILLSPECTOR_API_KEYS="
sk-or-xxx1|https://api.openai.com/v1|gpt-5.4
sk-or-xxx2|https://api.openai.com/v1|gpt-5.4
"
Single-key mode (backward-compatible — no pool needed)::
export OPENAI_API_KEY=sk-or-xxx1
When ``SKILLSPECTOR_API_KEYS`` is not set, :func:`create_api_key_pool_from_env`
returns ``None`` and the caller should fall back to the single-key provider path.
"""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from skillspector.logging_config import get_logger
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_API_KEYS_ENV = "SKILLSPECTOR_API_KEYS"
_DEFAULT_MAX_CONCURRENT_PER_KEY = 5
_MAX_RATE_LIMIT_RETRIES = 5
_BACKOFF_BASE_S = 30.0
_BACKOFF_CAP_S = 300.0
# ---------------------------------------------------------------------------
# ApiKey — single key tracked by the pool
# ---------------------------------------------------------------------------
@dataclass
class ApiKey:
"""A single API key with concurrency and rate-limit metadata.
Attributes
----------
key :
API key string (e.g. ``"sk-or-xxx"``).
base_url :
Optional base URL override for the provider endpoint.
model :
Model label to use with this key.
rate_limited :
``True`` when this key is cooling down after a 429 response.
rate_limited_until :
Monotonic timestamp when this key becomes eligible again after a
429. Only meaningful when *rate_limited* is ``True``.
consecutive_429 :
Count of consecutive rate-limit hits. Used to compute the next
backoff duration via :math:`30 \\times 2^n` seconds, capped at 300.
total_requests :
Cumulative request count served by this key. Used for
least-loaded scheduling.
active_requests :
Number of callers currently using this key.
max_concurrent :
Maximum number of simultaneous callers allowed on this key
(default 5). One key serves up to this many concurrent LLM calls.
"""
key: str
base_url: str | None
model: str
rate_limited: bool = False
rate_limited_until: float = 0.0
consecutive_429: int = 0
total_requests: int = 0
active_requests: int = 0
max_concurrent: int = _DEFAULT_MAX_CONCURRENT_PER_KEY
@property
def available(self) -> bool:
"""``True`` when this key can accept at least one more caller."""
return not self.rate_limited and self.active_requests < self.max_concurrent
# ---------------------------------------------------------------------------
# ApiKeyPool — multi-key load-balancer
# ---------------------------------------------------------------------------
class ApiKeyPool:
"""Thread-safe pool of API keys with per-key concurrency slots.
Each key has *max_concurrent* slots (default 5). :meth:`acquire` picks
the least-loaded available key — multiple callers can share the same key
as long as slots remain. Only rate-limited keys (HTTP 429) are taken
out of rotation; the pool only blocks when every non-rate-limited key
is at capacity.
Usage::
pool = ApiKeyPool([ApiKey("sk-a", ...), ApiKey("sk-b", ...)])
key = pool.acquire() # blocks only if all keys full
try:
llm_call(key)
pool.release(key, success=True)
except RateLimitError:
pool.release(key, success=False)
key = pool.acquire()
"""
def __init__(self, keys: list[ApiKey]) -> None:
if not keys:
raise ValueError("ApiKeyPool requires at least one key")
self._keys = list(keys)
self._lock = threading.Lock()
self._condition = threading.Condition(self._lock)
self._rate_limits_hit: int = 0
self._retry_successes: int = 0
self._total_requests_served: int = 0
self._peak_active_requests: int = 0
# -- Public API -----------------------------------------------------------
def acquire(self, timeout: float | None = None) -> ApiKey:
"""Acquire a slot on the least-loaded available key.
Scheduling priority:
1. **Recovered keys** — rate-limited keys whose backoff has expired
become available again.
2. **Least-loaded key** — among available keys, pick the one with
the fewest ``active_requests``.
3. **Block** — if every non-rate-limited key is at capacity, wait
for a slot to free up or a rate-limited key to recover.
Parameters
----------
timeout :
Maximum seconds to wait. ``None`` means wait indefinitely.
Returns
-------
ApiKey
A key with at least one available slot.
Raises
------
RuntimeError
If *timeout* expires before a slot becomes available.
"""
deadline = time.monotonic() + timeout if timeout is not None else None
with self._condition:
while True:
now = time.monotonic()
# Step 1: recover rate-limited keys whose backoff has expired
self._recover_expired_keys(now)
# Step 2: find available keys (not rate-limited, slots open)
available = [k for k in self._keys if k.available]
if available:
key = min(available, key=lambda k: k.active_requests)
key.active_requests += 1
key.total_requests += 1
self._total_requests_served += 1
_now_active = sum(k.active_requests for k in self._keys)
if _now_active > self._peak_active_requests:
self._peak_active_requests = _now_active
logger.debug(
"Pool: slot on key …%s (%d/%d active)",
key.key[-8:],
key.active_requests,
key.max_concurrent,
)
return key
# Step 3: no capacity — compute wait time
wait_for = self._next_available_in(now)
remaining = self._remaining_timeout(deadline)
if remaining is not None and remaining <= 0:
raise RuntimeError(
"ApiKeyPool: timed out waiting for available slot "
f"({self._capacity_summary()})"
)
if wait_for is None:
self._condition.wait(timeout=remaining)
else:
wait = min(wait_for, remaining or wait_for)
logger.debug(
"Pool: at capacity, waiting %.1fs (%s)",
wait,
self._capacity_summary(),
)
self._condition.wait(timeout=wait)
def try_acquire(self) -> ApiKey | None:
"""Non-blocking acquire — returns a key immediately or ``None``.
Unlike :meth:`acquire`, this never blocks. If a slot is available
right now, return the least-loaded key; otherwise return ``None``.
Useful in async contexts where blocking would stall the event loop.
"""
with self._lock:
self._recover_expired_keys(time.monotonic())
available = [k for k in self._keys if k.available]
if not available:
return None
key = min(available, key=lambda k: k.active_requests)
key.active_requests += 1
key.total_requests += 1
self._total_requests_served += 1
_now_active = sum(k.active_requests for k in self._keys)
if _now_active > self._peak_active_requests:
self._peak_active_requests = _now_active
return key
def release(self, key: ApiKey, *, success: bool = True) -> None:
"""Release a slot on *key* back to the pool.
Parameters
----------
key :
The key previously obtained from :meth:`acquire`.
success :
``True`` if the API call succeeded; ``False`` if it failed with
a rate-limit error (HTTP 429). On failure the key is marked
rate-limited with exponential backoff.
"""
with self._condition:
key.active_requests = max(0, key.active_requests - 1)
if success:
key.consecutive_429 = 0
logger.debug(
"Pool: released slot on key …%s (%d/%d active)",
key.key[-8:],
key.active_requests,
key.max_concurrent,
)
else:
key.consecutive_429 += 1
backoff = min(
_BACKOFF_BASE_S * (2 ** (key.consecutive_429 - 1)),
_BACKOFF_CAP_S,
)
key.rate_limited_until = time.monotonic() + backoff
key.rate_limited = True
self._rate_limits_hit += 1
logger.warning(
"Pool: key …%s rate-limited for %.0fs "
"(consecutive=%d)",
key.key[-8:],
backoff,
key.consecutive_429,
)
self._condition.notify_all()
def record_retry_success(self) -> None:
"""Increment the retry-success counter for reporting.
Only call this when a retry (after a key switch due to 429)
actually succeeds, not on every attempt.
"""
with self._lock:
self._retry_successes += 1
@property
def rate_limits_hit(self) -> int:
"""Total number of 429 responses encountered across all keys."""
with self._lock:
return self._rate_limits_hit
@property
def retry_successes(self) -> int:
"""Total number of successful retries after a key switch."""
with self._lock:
return self._retry_successes
@property
def keys_configured(self) -> int:
"""Total number of keys in the pool."""
return len(self._keys)
@property
def total_capacity(self) -> int:
"""Sum of ``max_concurrent`` across all keys."""
return sum(k.max_concurrent for k in self._keys)
@property
def active_requests(self) -> int:
"""Total active requests across all keys."""
with self._lock:
return sum(k.active_requests for k in self._keys)
def snapshot(self) -> dict[str, object]:
"""Return a snapshot dict suitable for report metadata."""
with self._lock:
rate_limited = sum(1 for k in self._keys if k.rate_limited)
active = sum(k.active_requests for k in self._keys)
return {
"keys_configured": len(self._keys),
"total_capacity": sum(k.max_concurrent for k in self._keys),
"active_requests": active,
"peak_active_requests": self._peak_active_requests,
"total_requests_served": self._total_requests_served,
"keys_rate_limited": rate_limited,
"keys_available": len(self._keys) - rate_limited,
"rate_limits_hit": self._rate_limits_hit,
"retry_successes": self._retry_successes,
}
# -- Internal -------------------------------------------------------------
def _recover_expired_keys(self, now: float) -> None:
"""Promote rate-limited keys whose backoff has expired."""
for k in self._keys:
if k.rate_limited and now >= k.rate_limited_until:
k.rate_limited = False
k.consecutive_429 = 0
logger.info(
"Pool: key …%s recovered (backoff expired)", k.key[-8:]
)
def _next_available_in(self, now: float) -> float | None:
"""Seconds until the earliest rate-limited key recovers, or ``None``."""
rate_limited = [k for k in self._keys if k.rate_limited]
if not rate_limited:
return None
earliest = min(k.rate_limited_until for k in rate_limited)
return max(0.0, earliest - now)
def _capacity_summary(self) -> str:
active = sum(k.active_requests for k in self._keys)
total = sum(k.max_concurrent for k in self._keys)
rate_limited = sum(1 for k in self._keys if k.rate_limited)
return (
f"{active}/{total} slots active, "
f"{rate_limited} key(s) rate-limited"
)
@staticmethod
def _remaining_timeout(deadline: float | None) -> float | None:
if deadline is None:
return None
return max(0.0, deadline - time.monotonic())
# ---------------------------------------------------------------------------
# PooledChatModel — transparent key-switching wrapper
# ---------------------------------------------------------------------------
class PooledChatModel:
"""LangChain-compatible chat model wrapper with transparent key switching.
Each :meth:`invoke` / :meth:`ainvoke` call acquires a key from the pool,
builds a :class:`~langchain_openai.ChatOpenAI` instance on the fly, and
releases the key when done. On rate-limit errors the wrapper releases
the key with ``success=False``, picks a different key, and retries.
Parameters
----------
pool :
An :class:`ApiKeyPool` with at least one configured key.
max_tokens :
``max_completion_tokens`` passed to each ``ChatOpenAI`` instance.
timeout :
Request timeout in seconds passed to each ``ChatOpenAI`` instance.
max_retries :
Maximum number of key-switch retries on rate-limit errors before
giving up.
"""
def __init__(
self,
pool: ApiKeyPool,
*,
max_tokens: int = 4096,
timeout: float = 30.0,
max_retries: int = _MAX_RATE_LIMIT_RETRIES,
) -> None:
self._pool = pool
self._max_tokens = max_tokens
self._timeout = timeout
self._max_retries = max_retries
# -- Public API -----------------------------------------------------------
def invoke(self, prompt: str) -> object:
"""Synchronous invoke with automatic key switching on rate-limit."""
return self._invoke_with_retry(prompt)
async def ainvoke(self, prompt: str) -> object:
"""Async invoke with automatic key switching on rate-limit."""
return await self._ainvoke_with_retry(prompt)
# -- Internal -------------------------------------------------------------
def _invoke_with_retry(self, prompt: str) -> object:
"""Sync retry loop — acquire slot, call LLM, release, retry on 429."""
last_exception: Exception | None = None
for attempt in range(self._max_retries + 1):
key = self._pool.acquire()
llm = self._build_llm(key)
try:
result = llm.invoke(prompt)
self._pool.release(key, success=True)
if attempt > 0:
self._pool.record_retry_success()
return result
except Exception as exc:
if self._is_rate_limit(exc) and attempt < self._max_retries:
self._pool.release(key, success=False)
logger.debug(
"PooledChatModel: rate-limited, retrying "
"(attempt %d/%d)",
attempt + 1,
self._max_retries,
)
continue
self._pool.release(key, success=True)
last_exception = exc
raise
raise RuntimeError(
f"PooledChatModel: exhausted {self._max_retries} retries "
"due to rate-limit errors"
) from last_exception
async def _ainvoke_with_retry(self, prompt: str) -> object:
"""Async retry loop — non-blocking acquire first, block only if full."""
import asyncio
last_exception: Exception | None = None
for attempt in range(self._max_retries + 1):
key = self._pool.try_acquire()
if key is None:
key = await asyncio.to_thread(self._pool.acquire)
llm = self._build_llm(key)
try:
result = await llm.ainvoke(prompt)
self._pool.release(key, success=True)
if attempt > 0:
self._pool.record_retry_success()
return result
except Exception as exc:
if self._is_rate_limit(exc) and attempt < self._max_retries:
self._pool.release(key, success=False)
logger.debug(
"PooledChatModel: rate-limited, retrying "
"(attempt %d/%d)",
attempt + 1,
self._max_retries,
)
continue
self._pool.release(key, success=True)
last_exception = exc
raise
raise RuntimeError(
f"PooledChatModel: exhausted {self._max_retries} retries "
"due to rate-limit errors"
) from last_exception
def _build_llm(self, key: ApiKey):
"""Build a fresh :class:`~langchain_openai.ChatOpenAI` for *key*."""
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
try:
import httpx
_timeout = httpx.Timeout(self._timeout, connect=8.0)
except ImportError:
_timeout = self._timeout
return ChatOpenAI(
model=key.model,
base_url=key.base_url,
api_key=SecretStr(key.key),
max_completion_tokens=self._max_tokens,
timeout=_timeout,
)
@staticmethod
def _is_rate_limit(exc: Exception) -> bool:
"""Detect rate-limit errors from common LLM provider SDKs."""
try:
import openai
if isinstance(exc, openai.RateLimitError):
return True
except ImportError:
pass
message = str(exc).lower()
for marker in ("429", "rate limit", "rate_limit", "too many requests"):
if marker in message:
return True
return False
# ---------------------------------------------------------------------------
# Factory — create pool from environment
# ---------------------------------------------------------------------------
def create_api_key_pool_from_env(
max_concurrent_per_key: int = _DEFAULT_MAX_CONCURRENT_PER_KEY,
) -> ApiKeyPool | None:
"""Build an :class:`ApiKeyPool` from environment variables.
Reads ``SKILLSPECTOR_API_KEYS`` — a newline- or semicolon-delimited list
of ``key|base_url|model`` entries.
Also supports a fallback format where multiple keys are specified via
sequentially numbered env vars ``OPENAI_API_KEY``, ``OPENAI_API_KEY_2``,
etc.
Parameters
----------
max_concurrent_per_key :
Maximum simultaneous requests allowed per key (default 5).
With 10 keys this gives 50 aggregate slots.
Returns
-------
ApiKeyPool or None
``None`` when no multi-key configuration is detected.
"""
keys: list[ApiKey] = []
raw = os.environ.get(_API_KEYS_ENV, "").strip()
if raw:
for line in raw.replace(";", "\n").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split("|")
if len(parts) < 1:
continue
key_str = parts[0].strip()
base_url = parts[1].strip() if len(parts) > 1 else None
model = parts[2].strip() if len(parts) > 2 else "gpt-5.4"
keys.append(ApiKey(
key=key_str, base_url=base_url, model=model,
max_concurrent=max_concurrent_per_key,
))
if not keys:
base = os.environ.get("OPENAI_API_KEY", "").strip()
base_url = os.environ.get("OPENAI_BASE_URL", None)
if base:
keys.append(ApiKey(
key=base, base_url=base_url, model="gpt-5.4",
max_concurrent=max_concurrent_per_key,
))
for idx in range(2, 10):
extra = os.environ.get(f"OPENAI_API_KEY_{idx}", "").strip()
if not extra:
break
keys.append(ApiKey(
key=extra, base_url=base_url, model="gpt-5.4",
max_concurrent=max_concurrent_per_key,
))
if len(keys) <= 1:
return None
total_cap = len(keys) * max_concurrent_per_key
logger.info(
"ApiKeyPool: %d keys × %d slots = %d total capacity",
len(keys), max_concurrent_per_key, total_cap,
)
return ApiKeyPool(keys)