feat(provider): 重构模型测试对话框,加固 Vertex AI 传输层

模型测试:
- 将消息输入替换为完整 JSON 请求体编辑器,支持格式化和校验
- 新增端点选择面板,测试前可选择目标端点
- 新增调试检查器,可查看每次尝试的请求/响应头和体
- 结果视图改用 HorizontalRequestTimeline 组件展示请求追踪
- endpoint_checker 返回完整调试数据,通过 candidate extra_data 持久化

Vertex AI:
- 改进上下文检测逻辑,不再仅依赖 provider_type,支持从 base_url 推断
- Service Account 密钥现支持自动拉取模型(使用 auth_config 而非 api_key)
- 移除 Gemini Developer API 回退,API Key 仅走 Express 模式
- 端点表单为 Vertex AI 显示格式特定的默认路径模板
- 密钥格式校验仅在 auth_type/api_formats 变更时执行

其他:
- 禁用 ClaudeCode 提供商类型创建入口
- Dialog 组件新增 closeOnBackdrop 属性
This commit is contained in:
fawney19
2026-03-19 23:52:17 +08:00
parent e4ebd5cca1
commit 6984984c22
31 changed files with 1609 additions and 434 deletions

View File

@@ -6,7 +6,9 @@ import pytest
import src.services.model.fetch_scheduler as fetch_scheduler_module
from src.services.model.fetch_scheduler import (
EndpointFetchConfig,
ModelFetchScheduler,
PreparedModelsFetchContext,
_aggregate_models_for_cache,
_run_key_fetch_workers,
)
@@ -108,3 +110,70 @@ async def test_perform_fetch_all_keys_scans_in_batches(monkeypatch: pytest.Monke
assert batch_requests == [None, "b"]
assert processed_batches == [["a", "b"], ["c"]]
@pytest.mark.asyncio
async def test_fetch_models_for_key_by_id_vertex_service_account_uses_auth_config(
monkeypatch: pytest.MonkeyPatch,
) -> None:
scheduler = ModelFetchScheduler()
captured: dict[str, object] = {}
prepared = PreparedModelsFetchContext(
key_id="key-vertex-sa",
provider_id="provider-vertex",
provider_name="Vertex",
provider_type="vertex_ai",
auth_type="service_account",
encrypted_api_key="ENC_PLACEHOLDER",
encrypted_auth_config="ENC_AUTH_CONFIG",
format_to_endpoint={
"gemini:chat": EndpointFetchConfig(base_url="https://aiplatform.googleapis.com"),
},
proxy_config=None,
)
monkeypatch.setattr(scheduler, "_prepare_fetch_context", lambda key_id: prepared)
async def fake_fetch_models_for_key(ctx, *, timeout_seconds: float):
captured["api_key_value"] = ctx.api_key_value
captured["auth_config"] = ctx.auth_config
captured["timeout_seconds"] = timeout_seconds
return ([], [], True, None)
async def fake_update_key_after_fetch(
key_id: str,
provider_id: str,
provider_name: str,
all_models: list[dict],
errors: list[str],
has_success: bool,
upstream_metadata=None,
) -> str:
captured["update_key_id"] = key_id
captured["update_provider_id"] = provider_id
return "success"
def fake_decrypt(value: str) -> str:
if value == "ENC_AUTH_CONFIG":
return (
'{"project_id":"demo-project","client_email":"svc@example.com",'
'"private_key":"-----BEGIN PRIVATE KEY-----\\nTEST\\n-----END PRIVATE KEY-----\\n"}'
)
raise AssertionError(f"unexpected decrypt call for {value}")
monkeypatch.setattr(fetch_scheduler_module, "fetch_models_for_key", fake_fetch_models_for_key)
monkeypatch.setattr(scheduler, "_update_key_after_fetch", fake_update_key_after_fetch)
monkeypatch.setattr(fetch_scheduler_module.crypto_service, "decrypt", fake_decrypt)
result = await scheduler._fetch_models_for_key_by_id("key-vertex-sa")
assert result == "success"
assert captured["api_key_value"] == "__placeholder__"
assert captured["auth_config"] == {
"project_id": "demo-project",
"client_email": "svc@example.com",
"private_key": "-----BEGIN PRIVATE KEY-----\nTEST\n-----END PRIVATE KEY-----\n",
}
assert captured["update_key_id"] == "key-vertex-sa"
assert captured["update_provider_id"] == "provider-vertex"

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import sys
import types
from contextlib import contextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, cast
@@ -167,6 +168,42 @@ def test_prepare_update_payload_vertex_to_oauth_clears_auth_config(
assert prepared.update_data["api_key"] == "ENC:__placeholder__"
def test_validate_vertex_api_formats_api_key_allows_gemini_only() -> None:
command_module._validate_vertex_api_formats("vertex_ai", "api_key", ["gemini:chat"])
with pytest.raises(InvalidRequestException, match="claude:chat"):
command_module._validate_vertex_api_formats("vertex_ai", "api_key", ["claude:chat"])
def test_validate_vertex_api_formats_service_account_allows_gemini_and_claude() -> None:
command_module._validate_vertex_api_formats("vertex_ai", "service_account", ["claude:chat"])
command_module._validate_vertex_api_formats("vertex_ai", "service_account", ["gemini:chat"])
command_module._validate_vertex_api_formats(
"vertex_ai", "service_account", ["gemini:chat", "claude:chat"]
)
with pytest.raises(InvalidRequestException, match="openai:chat"):
command_module._validate_vertex_api_formats("vertex_ai", "service_account", ["openai:chat"])
def test_prepare_update_payload_allows_unrelated_update_for_legacy_vertex_combo() -> None:
key = _build_key(
auth_type="service_account",
api_formats=["gemini:chat"],
provider=SimpleNamespace(provider_type="vertex_ai"),
)
key_data = EndpointAPIKeyUpdate.model_validate({"name": "legacy-key"})
prepared = command_module._prepare_update_key_payload(
db=cast(Any, _NoQueryDB()),
key=cast(Any, key),
key_id="key-1",
key_data=key_data,
)
assert prepared.update_data["name"] == "legacy-key"
def test_clear_oauth_invalid_response_invalidates_caches(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -346,6 +383,12 @@ async def test_batch_delete_endpoint_keys_response_cleans_related_references(
]
db = _FakeBatchDeleteDB(keys)
@contextmanager
def _fake_get_db_context() -> Any:
yield db
monkeypatch.setattr(command_module, "get_db_context", _fake_get_db_context)
result = await command_module.batch_delete_endpoint_keys_response(
cast(Any, db),
["key-1", "key-2"],

View File

@@ -1,4 +1,7 @@
from dataclasses import dataclass
from types import SimpleNamespace
import pytest
from src.services.provider.transport import build_provider_url
@@ -61,3 +64,52 @@ def test_gemini_non_stream_does_not_add_alt() -> None:
assert url.endswith("/v1beta/models/gemini-1.5-pro:generateContent")
assert "alt=" not in url
def test_vertex_gemini_api_key_base_url_uses_vertex_transport_without_provider_type(
monkeypatch: pytest.MonkeyPatch,
) -> None:
endpoint = _DummyEndpoint(
base_url="https://aiplatform.googleapis.com",
api_format="gemini:chat",
)
key = SimpleNamespace(auth_type="api_key", api_key="enc-key")
from src.core.crypto import crypto_service
monkeypatch.setattr(crypto_service, "decrypt", lambda value: "test-key")
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-3.1-pro-preview"},
is_stream=False,
key=key, # type: ignore[arg-type] - test stub
)
assert (
url == "https://aiplatform.googleapis.com/v1/publishers/google/models/"
"gemini-3.1-pro-preview:generateContent?key=test-key"
)
def test_vertex_gemini_service_account_base_url_uses_vertex_transport_without_provider_type() -> (
None
):
endpoint = _DummyEndpoint(
base_url="https://aiplatform.googleapis.com",
api_format="gemini:chat",
)
key = SimpleNamespace(auth_type="service_account", auth_config={"project_id": "demo-project"})
url = build_provider_url(
endpoint, # type: ignore[arg-type] - test stub
path_params={"model": "gemini-3.1-pro-preview"},
is_stream=False,
key=key, # type: ignore[arg-type] - test stub
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
assert (
url == "https://aiplatform.googleapis.com/v1/projects/demo-project/locations/global/"
"publishers/google/models/gemini-3.1-pro-preview:generateContent"
)

View File

@@ -52,7 +52,7 @@ async def test_fetch_models_for_key_vertex_api_key_custom_fetcher() -> None:
@pytest.mark.asyncio
async def test_fetch_models_for_key_vertex_service_account_ignores_soft_404_when_success() -> None:
async def test_fetch_models_for_key_vertex_service_account_fetches_google_and_claude() -> None:
auth_config = {
"project_id": "demo-project",
"client_email": "svc@example.iam.gserviceaccount.com",
@@ -74,16 +74,27 @@ async def test_fetch_models_for_key_vertex_service_account_ignores_soft_404_when
(
[
{
"id": "gemini-2.0-flash",
"id": "gemini-3.1-pro-preview",
"owned_by": "google",
"display_name": "Gemini 2.0 Flash",
"display_name": "Gemini 3.1 Pro Preview",
"api_format": "gemini:chat",
}
],
None,
True,
),
([], "HTTP 404: not found", False),
(
[
{
"id": "claude-3-7-sonnet@20250219",
"owned_by": "anthropic",
"display_name": "Claude 3.7 Sonnet",
"api_format": "claude:chat",
}
],
None,
True,
),
]
with (
@@ -111,7 +122,8 @@ async def test_fetch_models_for_key_vertex_service_account_ignores_soft_404_when
assert errors == []
assert meta is None
ids = {m.get("id") for m in models}
assert "gemini-2.0-flash" in ids
assert "gemini-3.1-pro-preview" in ids
assert "claude-3-7-sonnet@20250219" in ids
@pytest.mark.asyncio
@@ -132,7 +144,6 @@ async def test_fetch_models_for_key_vertex_api_key_returns_soft_404_when_all_fai
AsyncMock(
side_effect=[
([], "HTTP 404: not found", False),
([], "HTTP 404: not found", False),
]
),
),

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
import pytest
from src.core.exceptions import InvalidRequestException
from src.services.provider.adapters.vertex_ai.transport import build_vertex_ai_url
def test_build_vertex_ai_url_uses_express_mode_for_gemini_api_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
key = SimpleNamespace(auth_type="api_key", api_key="enc-key")
from src.core.crypto import crypto_service
monkeypatch.setattr(crypto_service, "decrypt", lambda value: "test-key")
url = build_vertex_ai_url(
SimpleNamespace(),
is_stream=True,
effective_query_params={"foo": "bar"},
path_params={"model": "gemini-2.5-pro"},
key=key,
)
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc == "aiplatform.googleapis.com"
assert parsed.path == "/v1/publishers/google/models/gemini-2.5-pro:streamGenerateContent"
assert parse_qs(parsed.query) == {
"foo": ["bar"],
"key": ["test-key"],
"alt": ["sse"],
}
def test_build_vertex_ai_url_uses_standard_vertex_path_for_gemini_service_account() -> None:
key = SimpleNamespace(auth_type="service_account")
url = build_vertex_ai_url(
SimpleNamespace(),
is_stream=False,
effective_query_params={"foo": "bar", "beta": "1"},
path_params={"model": "gemini-3.1-pro-preview"},
key=key,
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc == "aiplatform.googleapis.com"
assert (
parsed.path
== "/v1/projects/demo-project/locations/global/publishers/google/models/gemini-3.1-pro-preview:generateContent"
)
assert parse_qs(parsed.query) == {"foo": ["bar"]}
def test_build_vertex_ai_url_rejects_claude_api_key() -> None:
key = SimpleNamespace(auth_type="api_key")
with pytest.raises(InvalidRequestException, match="Claude 模型"):
build_vertex_ai_url(
SimpleNamespace(),
is_stream=False,
effective_query_params={},
path_params={"model": "claude-3-7-sonnet@20250219"},
key=key,
)
def test_build_vertex_ai_url_uses_standard_vertex_path_for_claude_service_account() -> None:
key = SimpleNamespace(auth_type="service_account")
url = build_vertex_ai_url(
SimpleNamespace(),
is_stream=False,
effective_query_params={"foo": "bar", "beta": "1"},
path_params={"model": "claude-3-7-sonnet@20250219"},
key=key,
decrypted_auth_config={"project_id": "demo-project", "region": "global"},
)
parsed = urlparse(url)
assert parsed.scheme == "https"
assert parsed.netloc == "aiplatform.googleapis.com"
assert (
parsed.path
== "/v1/projects/demo-project/locations/global/publishers/anthropic/models/claude-3-7-sonnet@20250219:rawPredict"
)
assert parse_qs(parsed.query) == {"foo": ["bar"]}