mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(oauth): 账号封禁前置 OAuth 验证、抽取 provider_context、完善账号状态分类
- 新增 verify_oauth_before_account_block:在标记账号封禁前先尝试刷新 token, 区分 OAuth 过期与真正的账号级封禁,避免误标 - 抽取 provider_context.py 统一解析 provider_type,解决 ORM detached 访问问题 - account_state 新增 workspace_deactivated 分类和 auto-removable 状态集合, 补充中文验证关键词匹配 - OAuth refresh 成功后仅清除可恢复的 token 错误,不再自动清除账号级 block - deploy.sh 依赖指纹改用纯 shell 实现,移除对 Python tomllib 的依赖 - 前端 Pool 管理页面新增筛选和批量操作优化 - 补充对应测试用例
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.services.orchestration.error_handler import ErrorHandlerService
|
||||
|
||||
|
||||
@@ -31,11 +35,16 @@ def _build_key() -> SimpleNamespace:
|
||||
)
|
||||
|
||||
|
||||
def test_mark_oauth_key_blocked_auto_remove_enabled(monkeypatch: Any) -> None:
|
||||
def test_mark_oauth_key_blocked_auto_remove_enabled_skips_verification_state(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
service = ErrorHandlerService(db=cast(Any, db))
|
||||
key = _build_key()
|
||||
provider = SimpleNamespace(config={"pool_advanced": {"auto_remove_banned_keys": True}})
|
||||
provider = SimpleNamespace(
|
||||
provider_type="codex",
|
||||
config={"pool_advanced": {"auto_remove_banned_keys": True}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ErrorHandlerService,
|
||||
@@ -46,8 +55,8 @@ def test_mark_oauth_key_blocked_auto_remove_enabled(monkeypatch: Any) -> None:
|
||||
service._mark_oauth_key_blocked(cast(Any, key), "req-1", provider=cast(Any, provider))
|
||||
|
||||
assert db.commit_count == 1
|
||||
assert db.deleted == [key]
|
||||
assert key.is_active is False
|
||||
assert db.deleted == []
|
||||
assert key.is_active is True
|
||||
assert str(key.oauth_invalid_reason).startswith("[ACCOUNT_BLOCK] ")
|
||||
|
||||
|
||||
@@ -61,5 +70,62 @@ def test_mark_oauth_key_blocked_auto_remove_disabled() -> None:
|
||||
|
||||
assert db.commit_count == 1
|
||||
assert db.deleted == []
|
||||
assert key.is_active is False
|
||||
assert key.is_active is True
|
||||
assert str(key.oauth_invalid_reason).startswith("[ACCOUNT_BLOCK] ")
|
||||
|
||||
|
||||
def test_mark_oauth_key_blocked_auto_remove_enabled_for_deactivated_account(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
service = ErrorHandlerService(db=cast(Any, db))
|
||||
key = _build_key()
|
||||
provider = SimpleNamespace(
|
||||
provider_type="codex",
|
||||
config={"pool_advanced": {"auto_remove_banned_keys": True}},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ErrorHandlerService,
|
||||
"_schedule_auto_cleanup_after_delete",
|
||||
staticmethod(lambda **kwargs: None),
|
||||
)
|
||||
|
||||
service._mark_oauth_key_blocked(
|
||||
cast(Any, key),
|
||||
"req-1",
|
||||
reason="account has been deactivated",
|
||||
provider=cast(Any, provider),
|
||||
)
|
||||
|
||||
assert db.commit_count == 1
|
||||
assert db.deleted == [key]
|
||||
assert key.is_active is True
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] account has been deactivated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_oauth_before_account_block_skips_when_refresh_marks_token_expired(
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
db = _FakeDB()
|
||||
service = ErrorHandlerService(db=cast(Any, db))
|
||||
key = _build_key()
|
||||
endpoint = SimpleNamespace()
|
||||
|
||||
fake_module = types.ModuleType("src.services.provider.auth")
|
||||
|
||||
async def _fake_get_provider_auth(*_args: Any, **_kwargs: Any) -> None:
|
||||
key.oauth_invalid_reason = "[OAUTH_EXPIRED] token expired"
|
||||
|
||||
fake_module.get_provider_auth = _fake_get_provider_auth
|
||||
monkeypatch.setitem(sys.modules, "src.services.provider.auth", fake_module)
|
||||
|
||||
should_mark = await service._verify_oauth_before_account_block(
|
||||
endpoint=cast(Any, endpoint),
|
||||
key=cast(Any, key),
|
||||
request_id="req-1",
|
||||
candidate_reason="Google 要求验证账号",
|
||||
)
|
||||
|
||||
assert should_mark is False
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.services.provider.pool.account_state import resolve_pool_account_state
|
||||
from src.services.provider.pool.account_state import (
|
||||
resolve_pool_account_state,
|
||||
should_auto_remove_account_state,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_from_kiro_banned_metadata() -> None:
|
||||
@@ -41,6 +44,18 @@ def test_resolve_from_structured_oauth_reason_verification() -> None:
|
||||
assert state.reason == "Google requires verification"
|
||||
|
||||
|
||||
def test_resolve_from_structured_oauth_reason_verification_chinese() -> None:
|
||||
state = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google 要求验证账号",
|
||||
)
|
||||
assert state.blocked is True
|
||||
assert state.code == "account_verification"
|
||||
assert state.label == "需要验证"
|
||||
assert state.reason == "Google 要求验证账号"
|
||||
|
||||
|
||||
def test_resolve_from_structured_oauth_reason_suspended() -> None:
|
||||
state = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
@@ -155,3 +170,25 @@ def test_request_failed_prefix_does_not_block() -> None:
|
||||
oauth_invalid_reason="[REQUEST_FAILED] Codex 账户访问受限 (403)",
|
||||
)
|
||||
assert state.blocked is False
|
||||
|
||||
|
||||
def test_auto_remove_state_excludes_token_expired_and_verification() -> None:
|
||||
expired = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[OAUTH_EXPIRED] token invalidated",
|
||||
)
|
||||
verification = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google 要求验证账号",
|
||||
)
|
||||
disabled = resolve_pool_account_state(
|
||||
provider_type="codex",
|
||||
upstream_metadata=None,
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] account has been deactivated",
|
||||
)
|
||||
|
||||
assert should_auto_remove_account_state(expired) is False
|
||||
assert should_auto_remove_account_state(verification) is False
|
||||
assert should_auto_remove_account_state(disabled) is True
|
||||
|
||||
@@ -25,7 +25,7 @@ def test_select_probe_key_ids_selects_silent_keys_only() -> None:
|
||||
|
||||
keys = [
|
||||
_key("k1"), # never used, should be probed
|
||||
_key("k2", last_used_at=now - timedelta(minutes=2)), # recently used, skip
|
||||
_key("k2", last_used_at=now - timedelta(minutes=2)), # recently used,仍可定期探测
|
||||
_key(
|
||||
"k3",
|
||||
upstream_metadata={"codex": {"updated_at": now_ts - (20 * 60)}},
|
||||
@@ -40,10 +40,10 @@ def test_select_probe_key_ids_selects_silent_keys_only() -> None:
|
||||
last_probe_timestamps={},
|
||||
limit=0,
|
||||
)
|
||||
assert selected == ["k1", "k3"]
|
||||
assert selected == ["k1", "k2", "k3"]
|
||||
|
||||
|
||||
def test_select_probe_key_ids_resets_probe_window_after_key_usage() -> None:
|
||||
def test_select_probe_key_ids_keeps_periodic_probe_even_after_recent_usage() -> None:
|
||||
now = datetime(2026, 3, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
now_ts = int(now.timestamp())
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_select_probe_key_ids_resets_probe_window_after_key_usage() -> None:
|
||||
)
|
||||
]
|
||||
|
||||
# 上一次主动探测非常早,但 key 刚刚被真实流量使用,应跳过本次探测
|
||||
# 即使 key 刚刚被真实流量使用,只要上次额度刷新/主动探测已过窗口,仍应继续定期探测
|
||||
selected = _select_probe_key_ids(
|
||||
keys=keys, # type: ignore[arg-type]
|
||||
provider_type="codex",
|
||||
@@ -64,7 +64,7 @@ def test_select_probe_key_ids_resets_probe_window_after_key_usage() -> None:
|
||||
last_probe_timestamps={"k1": now_ts - (25 * 60)},
|
||||
limit=0,
|
||||
)
|
||||
assert selected == []
|
||||
assert selected == ["k1"]
|
||||
|
||||
|
||||
def test_select_probe_key_ids_applies_limit_by_oldest_anchor_first() -> None:
|
||||
@@ -72,9 +72,9 @@ def test_select_probe_key_ids_applies_limit_by_oldest_anchor_first() -> None:
|
||||
now_ts = int(now.timestamp())
|
||||
|
||||
keys = [
|
||||
_key("k1", last_used_at=now - timedelta(minutes=60)),
|
||||
_key("k2", last_used_at=now - timedelta(minutes=50)),
|
||||
_key("k3", last_used_at=now - timedelta(minutes=40)),
|
||||
_key("k1", upstream_metadata={"codex": {"updated_at": now_ts - (60 * 60)}}),
|
||||
_key("k2", upstream_metadata={"codex": {"updated_at": now_ts - (50 * 60)}}),
|
||||
_key("k3", upstream_metadata={"codex": {"updated_at": now_ts - (40 * 60)}}),
|
||||
]
|
||||
|
||||
selected = _select_probe_key_ids(
|
||||
|
||||
@@ -83,6 +83,20 @@ def test_account_state_takes_priority_over_manual_disabled() -> None:
|
||||
assert summary.reason == "account_forbidden"
|
||||
|
||||
|
||||
def test_workspace_deactivated_uses_specific_reason_code() -> None:
|
||||
dimensions = evaluate_pool_scheduling_dimensions(
|
||||
_snapshot(
|
||||
account_blocked=True,
|
||||
account_block_label="工作区停用",
|
||||
account_block_reason="deactivated_workspace",
|
||||
)
|
||||
)
|
||||
summary = summarize_pool_scheduling_dimensions(dimensions)
|
||||
|
||||
assert summary.status == "blocked"
|
||||
assert summary.reason == "workspace_deactivated"
|
||||
|
||||
|
||||
def test_summary_degraded_when_cost_reaches_soft_threshold() -> None:
|
||||
dimensions = evaluate_pool_scheduling_dimensions(
|
||||
_snapshot(cost_window_usage=8200, cost_limit=10000, cost_soft_threshold_percent=80)
|
||||
|
||||
@@ -47,9 +47,7 @@ class _FakeSessionCtx:
|
||||
return False
|
||||
|
||||
|
||||
def _install_module(
|
||||
monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str, Any]
|
||||
) -> None:
|
||||
def _install_module(monkeypatch: pytest.MonkeyPatch, name: str, attrs: dict[str, Any]) -> None:
|
||||
fake_module = types.ModuleType(name)
|
||||
for key, value in attrs.items():
|
||||
setattr(fake_module, key, value)
|
||||
@@ -110,9 +108,7 @@ def test_mark_refresh_token_invalid_persists_detached_key(
|
||||
assert fake_db.committed is True
|
||||
assert key.oauth_invalid_at is not None
|
||||
assert row.oauth_invalid_at is not None
|
||||
assert str(key.oauth_invalid_reason).startswith(
|
||||
"[REFRESH_FAILED] Token 续期失败 (401)"
|
||||
)
|
||||
assert str(key.oauth_invalid_reason).startswith("[REFRESH_FAILED] Token 续期失败 (401)")
|
||||
assert "refresh_token_reused" in str(row.oauth_invalid_reason)
|
||||
|
||||
|
||||
@@ -153,6 +149,30 @@ def test_persist_refreshed_token_clears_legacy_token_invalidated_account_block(
|
||||
assert key.oauth_invalid_reason is None
|
||||
|
||||
|
||||
def test_persist_refreshed_token_preserves_true_account_block(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key = SimpleNamespace(
|
||||
id="key-1",
|
||||
api_key="old-api",
|
||||
auth_config="old-config",
|
||||
oauth_invalid_at=datetime.now(timezone.utc),
|
||||
oauth_invalid_reason="[ACCOUNT_BLOCK] Google requires verification",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
module, "object_session", lambda _key: (_ for _ in ()).throw(RuntimeError())
|
||||
)
|
||||
monkeypatch.setattr(module.crypto_service, "encrypt", lambda value: f"enc:{value}")
|
||||
|
||||
module._persist_refreshed_token(key, "new-token", {"refresh_token": "rt-2"})
|
||||
|
||||
assert key.api_key == "enc:new-token"
|
||||
assert key.auth_config == 'enc:{"refresh_token": "rt-2"}'
|
||||
assert key.oauth_invalid_at is not None
|
||||
assert key.oauth_invalid_reason == "[ACCOUNT_BLOCK] Google requires verification"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_generic_oauth_token_persists_enriched_account_name(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -9,6 +11,32 @@ from src.core.vertex_auth import VertexAuthService
|
||||
from src.services.provider.auth import get_provider_auth
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeSessionCtx:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def __enter__(self) -> "_FakeSessionCtx":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
_ = exc_type, exc, tb
|
||||
return False
|
||||
|
||||
def query(self, _model: object) -> _FakeQuery:
|
||||
return _FakeQuery(self._row)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_provider_auth_vertex_service_account_uses_provider_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -124,3 +152,76 @@ async def test_get_provider_auth_vertex_service_account_prefers_key_proxy(
|
||||
|
||||
assert auth is not None
|
||||
assert captured["proxy_config"] == key_proxy
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_provider_auth_vertex_service_account_uses_provider_id_lookup_without_touching_endpoint_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sa_json = {
|
||||
"client_email": "svc@example.iam.gserviceaccount.com",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\nTEST\n-----END PRIVATE KEY-----\n",
|
||||
"project_id": "demo-project",
|
||||
}
|
||||
provider_proxy = {"node_id": "provider-node", "enabled": True}
|
||||
|
||||
class _DetachedEndpoint:
|
||||
provider_id = "provider-1"
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
fake_provider = SimpleNamespace(
|
||||
id="provider-1", provider_type="vertex_ai", proxy=provider_proxy
|
||||
)
|
||||
fake_database = types.ModuleType("src.database")
|
||||
fake_database.create_session = lambda: _FakeSessionCtx(fake_provider)
|
||||
fake_models = types.ModuleType("src.models.database")
|
||||
fake_models.Provider = type("Provider", (), {"id": "id"})
|
||||
|
||||
monkeypatch.setitem(sys.modules, "src.database", fake_database)
|
||||
monkeypatch.setitem(sys.modules, "src.models.database", fake_models)
|
||||
monkeypatch.setattr(
|
||||
"src.core.crypto.crypto_service.decrypt",
|
||||
lambda value: json.dumps(sa_json) if value == "enc_cfg" else "",
|
||||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_build_proxy_client_kwargs(
|
||||
proxy_config: dict[str, object] | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
**_: object,
|
||||
) -> dict[str, object]:
|
||||
captured["proxy_config"] = proxy_config
|
||||
return {"timeout": timeout}
|
||||
|
||||
async def _fake_get_access_token(
|
||||
self: VertexAuthService,
|
||||
*,
|
||||
httpx_client_kwargs: dict[str, object] | None = None,
|
||||
) -> str:
|
||||
captured["httpx_client_kwargs"] = httpx_client_kwargs
|
||||
return "ya29.test-token"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.services.proxy_node.resolver.build_proxy_client_kwargs",
|
||||
_fake_build_proxy_client_kwargs,
|
||||
)
|
||||
monkeypatch.setattr(VertexAuthService, "get_access_token", _fake_get_access_token)
|
||||
|
||||
key = SimpleNamespace(
|
||||
auth_type="service_account",
|
||||
auth_config="enc_cfg",
|
||||
api_key="enc_key",
|
||||
provider_id="provider-1",
|
||||
proxy=None,
|
||||
)
|
||||
|
||||
auth = await get_provider_auth(_DetachedEndpoint(), key) # type: ignore[arg-type]
|
||||
|
||||
assert auth is not None
|
||||
assert captured["proxy_config"] == provider_proxy
|
||||
assert captured["httpx_client_kwargs"] == {"timeout": 30}
|
||||
|
||||
@@ -244,10 +244,10 @@ def test_clear_oauth_invalid_response_invalidates_caches(
|
||||
|
||||
result = command_module.clear_oauth_invalid_response(cast(Any, db), key_id="key-1")
|
||||
|
||||
assert result["message"] == "已清除 OAuth 失效标记,Key 已自动启用"
|
||||
assert result["message"] == "已清除 OAuth 失效标记"
|
||||
assert key.oauth_invalid_at is None
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert key.is_active is True
|
||||
assert key.is_active is False
|
||||
assert db.commit_count == 1
|
||||
assert cache_calls == [("key", "key-1"), ("models", None)]
|
||||
|
||||
|
||||
@@ -771,10 +771,10 @@ async def test_antigravity_refresher_forbidden_collects_updates_without_commit(
|
||||
)
|
||||
|
||||
assert result["status"] == "forbidden"
|
||||
assert result["auto_disabled"] is True
|
||||
assert result["auto_disabled"] is False
|
||||
assert key.is_active is True
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert state_updates["k1"]["is_active"] is False
|
||||
assert "is_active" not in state_updates["k1"]
|
||||
assert state_updates["k1"]["oauth_invalid_reason"].startswith("账户访问被禁止")
|
||||
assert metadata_updates["k1"]["antigravity"]["is_forbidden"] is True
|
||||
assert db.commit_count == 0
|
||||
@@ -911,7 +911,7 @@ async def test_kiro_refresher_runtime_401_marks_key_invalid(
|
||||
assert "401" in result["message"]
|
||||
assert key.is_active is True
|
||||
assert key.oauth_invalid_reason is None
|
||||
assert state_updates["k1"]["is_active"] is False
|
||||
assert "is_active" not in state_updates["k1"]
|
||||
assert state_updates["k1"]["oauth_invalid_reason"] == "Kiro Token 无效或已过期"
|
||||
assert db.commit_count == 0
|
||||
|
||||
|
||||
@@ -414,3 +414,53 @@ async def test_refresh_provider_quota_auto_removes_banned_keys_when_enabled(
|
||||
assert result["results"][0]["auto_removed"] is True
|
||||
assert deleted_side_effect_calls == [("p1", ["gpt-4o"])]
|
||||
assert redis_cleared == [("p1", "k1"), ("p1", "k1")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_provider_quota_does_not_auto_remove_oauth_expired_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
provider = SimpleNamespace(
|
||||
id="p1",
|
||||
provider_type=ProviderType.CODEX,
|
||||
endpoints=[SimpleNamespace(api_format="openai:cli", is_active=True)],
|
||||
config={"pool_advanced": {"auto_remove_banned_keys": True}},
|
||||
)
|
||||
key = SimpleNamespace(
|
||||
id="k1",
|
||||
name="K1",
|
||||
provider_id="p1",
|
||||
allowed_models=None,
|
||||
upstream_metadata={},
|
||||
is_active=True,
|
||||
oauth_invalid_at=None,
|
||||
oauth_invalid_reason=None,
|
||||
)
|
||||
db = _FakeDB(provider=provider, keys=[key])
|
||||
|
||||
async def _fake_handler(**kwargs: Any) -> dict[str, Any]:
|
||||
state_updates = kwargs["state_updates"]
|
||||
state_updates["k1"] = {
|
||||
"oauth_invalid_at": "expired-at",
|
||||
"oauth_invalid_reason": "[OAUTH_EXPIRED] token invalidated",
|
||||
}
|
||||
return {"key_id": "k1", "key_name": "K1", "status": "error", "message": "expired"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
quota_service_module,
|
||||
"_select_refresh_endpoint",
|
||||
lambda provider, provider_type: provider.endpoints[0],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service_module, "_resolve_quota_refresh_handler", lambda _: _fake_handler
|
||||
)
|
||||
|
||||
result = await refresh_provider_quota_for_provider(
|
||||
db=cast(Any, db),
|
||||
provider_id="p1",
|
||||
codex_wham_usage_url="https://example.test/wham/usage",
|
||||
)
|
||||
|
||||
assert result["auto_removed"] == 0
|
||||
assert db.deleted == []
|
||||
assert key.oauth_invalid_reason == "[OAUTH_EXPIRED] token invalidated"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -16,6 +18,33 @@ class _DummyEndpoint:
|
||||
api_format: str
|
||||
custom_path: str | None = None
|
||||
provider: object | None = None
|
||||
provider_id: str | None = None
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeSessionCtx:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def __enter__(self) -> "_FakeSessionCtx":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
_ = exc_type, exc, tb
|
||||
return False
|
||||
|
||||
def query(self, _model: object) -> _FakeQuery:
|
||||
return _FakeQuery(self._row)
|
||||
|
||||
|
||||
def test_codex_openai_cli_uses_responses_path_without_v1_prefix() -> None:
|
||||
@@ -56,14 +85,16 @@ def test_codex_openai_cli_uses_compact_suffix_when_context_marked_compact() -> N
|
||||
api_format="openai:cli",
|
||||
provider=SimpleNamespace(provider_type="codex"),
|
||||
)
|
||||
set_codex_request_context(CodexRequestContext(is_compact=True))
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=False,
|
||||
)
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
set_codex_request_context(None)
|
||||
try:
|
||||
set_codex_request_context(CodexRequestContext(is_compact=True))
|
||||
url = build_provider_url(
|
||||
endpoint, # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=False,
|
||||
)
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
finally:
|
||||
set_codex_request_context(None)
|
||||
|
||||
|
||||
def test_codex_openai_compact_uses_compact_path_without_v1_prefix() -> None:
|
||||
@@ -78,3 +109,34 @@ def test_codex_openai_compact_uses_compact_path_without_v1_prefix() -> None:
|
||||
is_stream=False,
|
||||
)
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
|
||||
|
||||
def test_codex_openai_cli_uses_provider_id_lookup_without_touching_endpoint_provider(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class _DetachedEndpoint:
|
||||
base_url = "https://chatgpt.com/backend-api/codex"
|
||||
api_format = "openai:cli"
|
||||
custom_path = None
|
||||
provider_id = "provider-1"
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
fake_provider = SimpleNamespace(id="provider-1", provider_type="codex", proxy=None)
|
||||
fake_database = types.ModuleType("src.database")
|
||||
fake_database.create_session = lambda: _FakeSessionCtx(fake_provider)
|
||||
fake_models = types.ModuleType("src.models.database")
|
||||
fake_models.Provider = type("Provider", (), {"id": "id"})
|
||||
|
||||
monkeypatch.setitem(sys.modules, "src.database", fake_database)
|
||||
monkeypatch.setitem(sys.modules, "src.models.database", fake_models)
|
||||
|
||||
url = build_provider_url(
|
||||
_DetachedEndpoint(), # type: ignore[arg-type]
|
||||
path_params={"model": "ignored"},
|
||||
is_stream=True,
|
||||
)
|
||||
|
||||
assert url == "https://chatgpt.com/backend-api/codex/responses"
|
||||
|
||||
@@ -192,8 +192,8 @@ def test_resolve_pool_account_state_keeps_codex_metadata_block() -> None:
|
||||
)
|
||||
|
||||
assert state.blocked is True
|
||||
assert state.code == "account_forbidden"
|
||||
assert state.label == "访问受限"
|
||||
assert state.code == "workspace_deactivated"
|
||||
assert state.label == "工作区停用"
|
||||
assert state.reason == "deactivated_workspace"
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -19,6 +21,33 @@ class _DummyEndpoint:
|
||||
api_format: str
|
||||
config: dict | None = None
|
||||
provider: object | None = None
|
||||
provider_id: str | None = None
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||
return self
|
||||
|
||||
def first(self) -> object | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeSessionCtx:
|
||||
def __init__(self, row: object | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def __enter__(self) -> "_FakeSessionCtx":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
_ = exc_type, exc, tb
|
||||
return False
|
||||
|
||||
def query(self, _model: object) -> _FakeQuery:
|
||||
return _FakeQuery(self._row)
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_defaults_to_auto() -> None:
|
||||
@@ -59,6 +88,24 @@ def test_get_upstream_stream_policy_codex_compact_forces_non_stream() -> None:
|
||||
set_codex_request_context(None)
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_uses_explicit_provider_type_without_touching_endpoint_provider() -> (
|
||||
None
|
||||
):
|
||||
class _DetachedEndpoint:
|
||||
api_format = "openai:cli"
|
||||
config = None
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
ep = _DetachedEndpoint()
|
||||
|
||||
assert (
|
||||
get_upstream_stream_policy(ep, provider_type="codex") == UpstreamStreamPolicy.FORCE_STREAM
|
||||
)
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_codex_openai_compact_defaults_to_auto() -> None:
|
||||
ep = _DummyEndpoint(
|
||||
api_format="openai:compact",
|
||||
@@ -68,6 +115,30 @@ def test_get_upstream_stream_policy_codex_openai_compact_defaults_to_auto() -> N
|
||||
assert get_upstream_stream_policy(ep) == UpstreamStreamPolicy.AUTO
|
||||
|
||||
|
||||
def test_get_upstream_stream_policy_uses_provider_id_lookup_without_touching_endpoint_provider(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class _DetachedEndpoint:
|
||||
api_format = "openai:cli"
|
||||
config = None
|
||||
provider_id = "provider-1"
|
||||
|
||||
@property
|
||||
def provider(self) -> object:
|
||||
raise RuntimeError("detached endpoint provider should not be lazy-loaded")
|
||||
|
||||
fake_provider = SimpleNamespace(id="provider-1", provider_type="codex", proxy=None)
|
||||
fake_database = types.ModuleType("src.database")
|
||||
fake_database.create_session = lambda: _FakeSessionCtx(fake_provider)
|
||||
fake_models = types.ModuleType("src.models.database")
|
||||
fake_models.Provider = type("Provider", (), {"id": "id"})
|
||||
|
||||
monkeypatch.setitem(sys.modules, "src.database", fake_database)
|
||||
monkeypatch.setitem(sys.modules, "src.models.database", fake_models)
|
||||
|
||||
assert get_upstream_stream_policy(_DetachedEndpoint()) == UpstreamStreamPolicy.FORCE_STREAM
|
||||
|
||||
|
||||
def test_enforce_stream_mode_for_upstream_openai_chat_sets_stream_options_usage() -> None:
|
||||
body = {"stream": False}
|
||||
out = enforce_stream_mode_for_upstream(
|
||||
|
||||
Reference in New Issue
Block a user