mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
fix(codex): 配额刷新 401/403 不再自动禁用 key,区分软性请求失败与账户封禁
- 新增 OAUTH_REQUEST_FAILED_PREFIX 标记非 token 失效的 403 请求失败 - 引入 _merge_invalid_reason 合并逻辑,避免低优先级原因覆盖高优先级状态 - account_state 中 REFRESH_FAILED/REQUEST_FAILED 前缀不再触发封禁判定 - 401/403 返回 auto_disabled=False,不再直接设置 is_active=False
This commit is contained in:
@@ -14,6 +14,7 @@ from src.services.provider_keys.quota_reader import get_quota_reader
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
|
||||
OAUTH_REFRESH_FAILED_PREFIX = "[REFRESH_FAILED] "
|
||||
OAUTH_EXPIRED_PREFIX = "[OAUTH_EXPIRED] "
|
||||
OAUTH_REQUEST_FAILED_PREFIX = "[REQUEST_FAILED] "
|
||||
|
||||
# -- 按原因细分的关键词组 --
|
||||
# 封禁类 (suspended / banned)
|
||||
@@ -192,6 +193,12 @@ def _resolve_from_oauth_invalid_reason(reason: str | None) -> PoolAccountState |
|
||||
reason=cleaned or "OAuth Token 已过期且无法续期",
|
||||
)
|
||||
|
||||
if text.startswith(OAUTH_REFRESH_FAILED_PREFIX) or text.startswith(OAUTH_REQUEST_FAILED_PREFIX):
|
||||
return None
|
||||
|
||||
if text.startswith("["):
|
||||
return None
|
||||
|
||||
lowered = text.lower()
|
||||
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
|
||||
code, label = _classify_block_reason(text)
|
||||
@@ -229,6 +236,7 @@ __all__ = [
|
||||
"OAUTH_ACCOUNT_BLOCK_PREFIX",
|
||||
"OAUTH_EXPIRED_PREFIX",
|
||||
"OAUTH_REFRESH_FAILED_PREFIX",
|
||||
"OAUTH_REQUEST_FAILED_PREFIX",
|
||||
"PoolAccountState",
|
||||
"resolve_pool_account_state",
|
||||
]
|
||||
|
||||
@@ -19,6 +19,7 @@ from src.services.provider.oauth_token import looks_like_token_invalidated
|
||||
from src.services.provider.pool.account_state import (
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX,
|
||||
OAUTH_EXPIRED_PREFIX,
|
||||
OAUTH_REQUEST_FAILED_PREFIX,
|
||||
)
|
||||
from src.services.provider_keys.auth_type import normalize_auth_type
|
||||
from src.services.provider_keys.codex_usage_parser import (
|
||||
@@ -107,6 +108,45 @@ def _build_structured_invalid_reason(*, status_code: int, upstream_message: str
|
||||
return message
|
||||
|
||||
|
||||
def _build_soft_request_failure_reason(*, status_code: int, upstream_message: str | None) -> str:
|
||||
detail = str(upstream_message or "").strip() or f"Codex 请求失败 ({status_code})"
|
||||
return f"{OAUTH_REQUEST_FAILED_PREFIX}{detail}"
|
||||
|
||||
|
||||
def _get_current_invalid_reason(key: ProviderAPIKey) -> str:
|
||||
return str(getattr(key, "oauth_invalid_reason", None) or "").strip()
|
||||
|
||||
|
||||
def _merge_invalid_reason(current: str, candidate_reason: str) -> str:
|
||||
if not current:
|
||||
return candidate_reason
|
||||
if current.startswith(OAUTH_ACCOUNT_BLOCK_PREFIX):
|
||||
return current
|
||||
if current.startswith(OAUTH_EXPIRED_PREFIX) and candidate_reason.startswith(
|
||||
OAUTH_REQUEST_FAILED_PREFIX
|
||||
):
|
||||
return current
|
||||
return candidate_reason
|
||||
|
||||
|
||||
def _build_invalid_state_update(
|
||||
key: ProviderAPIKey,
|
||||
*,
|
||||
candidate_reason: str,
|
||||
) -> dict[str, Any]:
|
||||
current_reason = _get_current_invalid_reason(key)
|
||||
merged_reason = _merge_invalid_reason(current_reason, candidate_reason)
|
||||
if merged_reason == current_reason:
|
||||
return {
|
||||
"oauth_invalid_at": getattr(key, "oauth_invalid_at", None),
|
||||
"oauth_invalid_reason": merged_reason,
|
||||
}
|
||||
return {
|
||||
"oauth_invalid_at": datetime.now(timezone.utc),
|
||||
"oauth_invalid_reason": merged_reason,
|
||||
}
|
||||
|
||||
|
||||
async def refresh_codex_key_quota(
|
||||
*,
|
||||
db: Session,
|
||||
@@ -187,21 +227,20 @@ async def refresh_codex_key_quota(
|
||||
metadata_updates[key.id] = {"codex": header_quota}
|
||||
|
||||
if status_code == 401:
|
||||
state_updates[key.id] = {
|
||||
"is_active": False,
|
||||
"oauth_invalid_at": datetime.now(timezone.utc),
|
||||
"oauth_invalid_reason": _build_structured_invalid_reason(
|
||||
state_updates[key.id] = _build_invalid_state_update(
|
||||
key,
|
||||
candidate_reason=_build_structured_invalid_reason(
|
||||
status_code=401,
|
||||
upstream_message=err_msg,
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"status": "auth_invalid",
|
||||
"message": f"wham/usage API 返回状态码 401{f': {err_msg}' if err_msg else ''}",
|
||||
"status_code": 401,
|
||||
"auto_disabled": True,
|
||||
"auto_disabled": False,
|
||||
}
|
||||
|
||||
if status_code == 402:
|
||||
@@ -219,13 +258,13 @@ async def refresh_codex_key_quota(
|
||||
if oauth_plan_type and not codex_meta.get("plan_type"):
|
||||
codex_meta["plan_type"] = oauth_plan_type
|
||||
metadata_updates[key.id] = {"codex": codex_meta}
|
||||
state_updates[key.id] = {
|
||||
"oauth_invalid_at": datetime.now(timezone.utc),
|
||||
"oauth_invalid_reason": _build_structured_invalid_reason(
|
||||
state_updates[key.id] = _build_invalid_state_update(
|
||||
key,
|
||||
candidate_reason=_build_structured_invalid_reason(
|
||||
status_code=402,
|
||||
upstream_message=err_msg,
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
@@ -251,21 +290,26 @@ async def refresh_codex_key_quota(
|
||||
}
|
||||
|
||||
if status_code == 403:
|
||||
state_updates[key.id] = {
|
||||
"is_active": False,
|
||||
"oauth_invalid_at": datetime.now(timezone.utc),
|
||||
"oauth_invalid_reason": _build_structured_invalid_reason(
|
||||
candidate_reason = _build_structured_invalid_reason(
|
||||
status_code=403,
|
||||
upstream_message=err_msg,
|
||||
)
|
||||
if not looks_like_token_invalidated(err_msg):
|
||||
candidate_reason = _build_soft_request_failure_reason(
|
||||
status_code=403,
|
||||
upstream_message=err_msg,
|
||||
),
|
||||
}
|
||||
)
|
||||
state_updates[key.id] = _build_invalid_state_update(
|
||||
key,
|
||||
candidate_reason=candidate_reason,
|
||||
)
|
||||
return {
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"status": "forbidden",
|
||||
"message": f"wham/usage API 返回状态码 403{f': {err_msg}' if err_msg else ''}",
|
||||
"status_code": 403,
|
||||
"auto_disabled": True,
|
||||
"auto_disabled": False,
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -137,3 +137,21 @@ def test_resolve_from_structured_oauth_reason_token_invalidated() -> None:
|
||||
assert state.blocked is True
|
||||
assert state.code == "oauth_expired"
|
||||
assert state.label == "Token 失效"
|
||||
|
||||
|
||||
def test_refresh_failed_prefix_does_not_block_even_with_scary_keywords() -> None:
|
||||
state = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[REFRESH_FAILED] Token 续期失败 (401): account_deactivated",
|
||||
)
|
||||
assert state.blocked is False
|
||||
|
||||
|
||||
def test_request_failed_prefix_does_not_block() -> None:
|
||||
state = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[REQUEST_FAILED] Codex 账户访问受限 (403)",
|
||||
)
|
||||
assert state.blocked is False
|
||||
|
||||
@@ -150,7 +150,7 @@ async def test_codex_refresher_http_non_200_returns_error(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_http_401_marks_auth_invalid_and_disables(
|
||||
async def test_codex_refresher_http_401_marks_auth_invalid_without_disabling_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.provider_keys.quota_refresh import codex_refresher as module
|
||||
@@ -193,9 +193,8 @@ async def test_codex_refresher_http_401_marks_auth_invalid_and_disables(
|
||||
|
||||
assert result["status"] == "auth_invalid"
|
||||
assert result["status_code"] == 401
|
||||
assert result["auto_disabled"] is True
|
||||
assert result["auto_disabled"] is False
|
||||
assert metadata_updates == {}
|
||||
assert state_updates["k1"]["is_active"] is False
|
||||
assert str(state_updates["k1"]["oauth_invalid_reason"]).startswith("[OAUTH_EXPIRED]")
|
||||
|
||||
|
||||
@@ -311,11 +310,61 @@ async def test_codex_refresher_http_403_token_invalidated_marks_oauth_expired(
|
||||
|
||||
assert result["status"] == "forbidden"
|
||||
assert result["status_code"] == 403
|
||||
assert result["auto_disabled"] is True
|
||||
assert state_updates["k1"]["is_active"] is False
|
||||
assert result["auto_disabled"] is False
|
||||
assert str(state_updates["k1"]["oauth_invalid_reason"]).startswith("[OAUTH_EXPIRED]")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_http_403_generic_marks_soft_request_failed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from src.services.provider_keys.quota_refresh import codex_refresher as module
|
||||
|
||||
key = SimpleNamespace(
|
||||
id="k1", name="K1", api_key="enc", auth_type="api_key", auth_config=None, proxy=None
|
||||
)
|
||||
provider = SimpleNamespace(proxy=None)
|
||||
endpoint = SimpleNamespace()
|
||||
metadata_updates: dict[str, dict[str, Any]] = {}
|
||||
state_updates: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def _fake_auth_info(_endpoint: Any, _key: Any) -> Any:
|
||||
return None
|
||||
|
||||
_install_module(
|
||||
monkeypatch,
|
||||
"src.services.proxy_node.resolver",
|
||||
{
|
||||
"resolve_effective_proxy": lambda provider_proxy, key_proxy: None,
|
||||
"build_proxy_client_kwargs": lambda proxy, timeout: {"timeout": timeout},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(module, "get_provider_auth", _fake_auth_info)
|
||||
monkeypatch.setattr(module.crypto_service, "decrypt", lambda _v: "sk-test")
|
||||
response = _FakeResponse(
|
||||
status_code=403,
|
||||
payload={"error": {"message": "Access forbidden for this account."}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.httpx, "AsyncClient", lambda **kwargs: _FakeAsyncClient(response, **kwargs)
|
||||
)
|
||||
|
||||
result = await refresh_codex_key_quota(
|
||||
db=cast(Any, _FakeDB()),
|
||||
provider=cast(Any, provider),
|
||||
key=cast(Any, key),
|
||||
endpoint=cast(Any, endpoint),
|
||||
codex_wham_usage_url="https://example.test",
|
||||
metadata_updates=metadata_updates,
|
||||
state_updates=state_updates,
|
||||
)
|
||||
|
||||
assert result["status"] == "forbidden"
|
||||
assert result["status_code"] == 403
|
||||
assert result["auto_disabled"] is False
|
||||
assert str(state_updates["k1"]["oauth_invalid_reason"]).startswith("[REQUEST_FAILED]")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_refresher_success_updates_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user