mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -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"
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
),
|
||||
),
|
||||
|
||||
94
tests/services/test_vertex_ai_transport.py
Normal file
94
tests/services/test_vertex_ai_transport.py
Normal 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"]}
|
||||
Reference in New Issue
Block a user