feat(test,export,headers): 模型测试复用统一运行时、实时进度展示、导出增强与请求头大小写保留

- 模型测试 failover 从手动 FailoverEngine 改为 TaskService.execute_sync_candidates 统一运行时
- 前端新增实时 trace 轮询进度展示(候选状态、测试账号、进度条)
- 用户导出/导入支持明文 Key 优先(版本升至 1.2),新增 email_verified 字段
- SENSITIVE_CREDENTIAL_FIELDS 统一到 provider_ops/types.py,补充 refresh_token
- 请求头大小写保留机制(resolve_header_name_case + HeaderBuilder.add 语义修改)
- Codex envelope 移除合成头部,保留客户端原始请求头
- endpoint_checker 支持自定义超时透传
- 新增 x-forwarded-scheme 到上游丢弃头部列表
This commit is contained in:
fawney19
2026-03-06 21:06:45 +08:00
parent 7950ba7dc5
commit 90760da499
28 changed files with 1485 additions and 429 deletions

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from types import SimpleNamespace
import jwt
import pytest
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
from src.services.provider.adapters.codex.request_patching import (
maybe_patch_request_for_codex,
patch_openai_cli_request_for_codex,
@@ -110,42 +113,49 @@ def test_openai_cli_normalizer_request_from_internal_codex_variant_defaults_stor
assert out["store"] is False
def test_codex_envelope_extra_headers_includes_sse_accept_and_session() -> None:
def test_codex_envelope_extra_headers_does_not_inject_synthetic_headers() -> None:
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
headers = codex_oauth_envelope.extra_headers() or {}
assert headers.get("Accept") == "text/event-stream"
assert headers.get("Originator") == "codex_cli_rs"
assert headers.get("Version") == "0.101.0"
assert headers.get("Connection") == "Keep-Alive"
assert isinstance(headers.get("Session_id"), str)
assert headers.get("Session_id")
assert codex_oauth_envelope.extra_headers() is None
def test_codex_envelope_extra_headers_compact_uses_json_accept() -> None:
from src.services.provider.adapters.codex.context import (
CodexRequestContext,
set_codex_request_context,
def test_codex_passthrough_builder_preserves_real_codex_headers() -> None:
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
builder = PassthroughRequestBuilder()
endpoint = SimpleNamespace(api_family="openai", endpoint_kind="cli", header_rules=None)
key = SimpleNamespace(api_key="unused")
headers = builder.build_headers(
original_headers={
"accept": "text/event-stream",
"content-type": "application/json",
"user-agent": "Codex Desktop/0.108.0-alpha.12",
"originator": "Codex Desktop",
"x-codex-turn-metadata": '{"turn_id":"abc"}',
"x-forwarded-scheme": "https",
"host": "aether.hetunai.cn",
"content-length": "123",
},
endpoint=endpoint,
key=key,
pre_computed_auth=("Authorization", "Bearer upstream-token"),
envelope=codex_oauth_envelope,
)
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
set_codex_request_context(CodexRequestContext(is_compact=True))
headers = codex_oauth_envelope.extra_headers() or {}
assert headers.get("Accept") == "application/json"
set_codex_request_context(None)
def test_codex_envelope_extra_headers_uses_account_id_header() -> None:
from src.services.provider.adapters.codex.context import (
CodexRequestContext,
set_codex_request_context,
)
from src.services.provider.adapters.codex.envelope import codex_oauth_envelope
set_codex_request_context(CodexRequestContext(account_id="acc_123"))
headers = codex_oauth_envelope.extra_headers() or {}
assert headers.get("Chatgpt-Account-Id") == "acc_123"
set_codex_request_context(None)
assert headers["accept"] == "text/event-stream"
assert headers["content-type"] == "application/json"
assert headers["user-agent"] == "Codex Desktop/0.108.0-alpha.12"
assert headers["originator"] == "Codex Desktop"
assert headers["x-codex-turn-metadata"] == '{"turn_id":"abc"}'
assert headers["Authorization"] == "Bearer upstream-token"
assert "Version" not in headers
assert "Session_id" not in headers
assert "Connection" not in headers
assert "Chatgpt-Account-Id" not in headers
assert "host" not in headers
assert "content-length" not in headers
assert "x-forwarded-scheme" not in headers
def _encode_unsigned_jwt(payload: dict[str, object]) -> str:

View File

@@ -1,4 +1,5 @@
from src.api.admin.system import AdminExportConfigAdapter, AdminImportConfigAdapter
from src.services.provider_ops.types import SENSITIVE_CREDENTIAL_FIELDS
def test_export_key_api_formats_falls_back_to_provider_endpoints_when_none() -> None:
@@ -59,3 +60,57 @@ def test_import_key_api_formats_keeps_explicit_empty_list() -> None:
)
assert result == []
class _FakeCrypto:
def encrypt(self, value: str) -> str:
return f"enc:{value}"
def decrypt(self, value: str) -> str:
return value.removeprefix("enc:")
def test_provider_ops_sensitive_fields_include_refresh_token() -> None:
assert "refresh_token" in SENSITIVE_CREDENTIAL_FIELDS
def test_export_provider_config_decrypts_refresh_token() -> None:
adapter = AdminExportConfigAdapter()
config = {
"provider_ops": {
"connector": {
"credentials": {
"refresh_token": "enc:rt-1",
"api_key": "enc:key-1",
}
}
}
}
result = adapter._decrypt_provider_config(config, _FakeCrypto())
assert result["provider_ops"]["connector"]["credentials"]["refresh_token"] == "rt-1"
assert result["provider_ops"]["connector"]["credentials"]["api_key"] == "key-1"
assert config["provider_ops"]["connector"]["credentials"]["refresh_token"] == "enc:rt-1"
def test_import_provider_config_encrypts_refresh_token() -> None:
adapter = AdminImportConfigAdapter()
config = {
"provider_ops": {
"connector": {
"credentials": {
"refresh_token": "rt-1",
"api_key": "key-1",
}
}
}
}
result = adapter._encrypt_provider_config(config, _FakeCrypto())
assert result["provider_ops"]["connector"]["credentials"]["refresh_token"] == "enc:rt-1"
assert result["provider_ops"]["connector"]["credentials"]["api_key"] == "enc:key-1"
assert config["provider_ops"]["connector"]["credentials"]["refresh_token"] == "rt-1"

View File

@@ -0,0 +1,57 @@
from src.api.admin.system import AdminExportUsersAdapter, AdminImportUsersAdapter
from src.core.crypto import crypto_service
from src.models.database import ApiKey
def test_export_user_api_key_prefers_plaintext_key() -> None:
plaintext_key = "ak-user-plain-1"
key = ApiKey(
id="key-1",
user_id="user-1",
key_hash=ApiKey.hash_key(plaintext_key),
key_encrypted=crypto_service.encrypt(plaintext_key),
name="Demo Key",
is_standalone=False,
balance_used_usd=1.5,
current_balance_usd=8.5,
is_active=True,
)
data = AdminExportUsersAdapter._serialize_api_key(key, include_is_standalone=True)
assert data["key"] == plaintext_key
assert "key_encrypted" not in data
assert data["key_hash"] == ApiKey.hash_key(plaintext_key)
assert data["is_standalone"] is False
def test_import_user_api_key_material_reencrypts_plaintext_key() -> None:
plaintext_key = "ak-user-plain-2"
key_hash, key_encrypted = AdminImportUsersAdapter._resolve_api_key_material(
{
"key": plaintext_key,
"key_hash": "stale-hash",
"key_encrypted": "stale-ciphertext",
}
)
assert key_hash == ApiKey.hash_key(plaintext_key)
assert key_encrypted is not None
assert crypto_service.decrypt(key_encrypted) == plaintext_key
def test_import_user_api_key_material_keeps_legacy_encrypted_payload() -> None:
legacy_plaintext = "ak-user-legacy-1"
legacy_encrypted = crypto_service.encrypt(legacy_plaintext)
legacy_hash = ApiKey.hash_key(legacy_plaintext)
key_hash, key_encrypted = AdminImportUsersAdapter._resolve_api_key_material(
{
"key_hash": legacy_hash,
"key_encrypted": legacy_encrypted,
}
)
assert key_hash == legacy_hash
assert key_encrypted == legacy_encrypted

View File

@@ -65,7 +65,8 @@ class TestHeaderBuilder:
builder.add("authorization", "b")
built = builder.build()
assert len(built) == 1
assert list(built.values()) == ["b"]
assert built["Authorization"] == "b"
assert "authorization" not in built
def test_add_protected_does_not_override(self) -> None:
builder = HeaderBuilder()
@@ -135,7 +136,8 @@ class TestBuildUpstreamHeaders:
extra_headers={"User-Agent": "b"},
)
assert len([k for k in result if k.lower() == "user-agent"]) == 1
assert result["User-Agent"] == "b"
assert result["user-agent"] == "b"
assert "User-Agent" not in result
def test_default_content_type(self) -> None:
result = build_upstream_headers_for_endpoint({}, "openai:chat", "provider")
@@ -169,3 +171,35 @@ class TestCapabilityResolverHeaderParsing:
request_headers={"x-require-capability": "context_1m"}
)
assert reqs == {"context_1m": True}
class TestAuthHeaderCasePreservation:
def test_build_upstream_headers_preserves_lowercase_authorization_key(self) -> None:
result = build_upstream_headers_for_endpoint(
{"authorization": "Bearer client-token", "X-Test": "1"},
"openai:chat",
"provider",
)
assert "authorization" in result
assert "Authorization" not in result
assert result["authorization"] == "Bearer provider"
def test_passthrough_request_builder_preserves_lowercase_authorization_key(self) -> None:
from types import SimpleNamespace
from src.api.handlers.base.request_builder import PassthroughRequestBuilder
builder = PassthroughRequestBuilder()
endpoint = SimpleNamespace(api_family="openai", endpoint_kind="cli", header_rules=None)
key = SimpleNamespace(api_key="unused")
headers = builder.build_headers(
original_headers={"authorization": "Bearer client-token"},
endpoint=endpoint,
key=key,
pre_computed_auth=("Authorization", "Bearer provider-token"),
)
assert "authorization" in headers
assert "Authorization" not in headers
assert headers["authorization"] == "Bearer provider-token"

View File

@@ -2,7 +2,9 @@ from types import SimpleNamespace
from src.api.admin.provider_query import (
_build_direct_test_candidates,
_build_test_attempts_from_candidate_keys,
_filter_test_candidates_by_endpoint,
_resolve_test_effective_model,
)
@@ -36,3 +38,54 @@ def test_filter_test_candidates_by_endpoint_keeps_matching_candidates() -> None:
assert {candidate.endpoint.id for candidate in filtered} == {endpoint_a.id}
assert all(candidate.endpoint.id != endpoint_b.id for candidate in filtered)
def test_resolve_test_effective_model_prefers_pool_key_mapping() -> None:
provider, endpoint_a, _endpoint_b = _build_provider()
candidate = _build_direct_test_candidates(provider, endpoint_id=endpoint_a.id)[0] # type: ignore[arg-type]
pool_key = SimpleNamespace(id="pool-key", _pool_mapping_matched_model="mapped-model")
request = SimpleNamespace(mode="global", model_name="gpt-4")
effective = _resolve_test_effective_model(
provider=provider, # type: ignore[arg-type]
candidate=candidate,
request=request, # type: ignore[arg-type]
gm_obj=None,
key=pool_key,
)
assert effective == "mapped-model"
def test_build_test_attempts_from_candidate_keys_includes_retry_index() -> None:
candidate_keys = [
SimpleNamespace(
candidate_index=2,
retry_index=1,
key_id="key-b",
key_name="Key B",
auth_type="api_key",
status="failed",
skip_reason=None,
error_message="timeout",
status_code=504,
latency_ms=1200,
)
]
attempts = _build_test_attempts_from_candidate_keys(
candidate_keys=candidate_keys,
candidate_meta_by_pair={
(2, "key-b"): {
"endpoint_api_format": "openai:chat",
"endpoint_base_url": "https://example.test/v1",
"effective_model": "mapped-model",
}
},
candidate_meta_by_index={},
)
assert len(attempts) == 1
assert attempts[0].retry_index == 1
assert attempts[0].effective_model == "mapped-model"
assert attempts[0].endpoint_api_format == "openai:chat"