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:
fawney19
2026-03-16 01:12:35 +08:00
parent 131471a13f
commit 025e979935
4 changed files with 141 additions and 22 deletions

View File

@@ -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

View File

@@ -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,