feat(vertex-ai): 重构 Vertex AI 为插件化 adapter,支持 service_account 认证与动态路由

将 Vertex AI 从 transport.py 的硬编码逻辑重构为独立的 plugin adapter,
支持 service_account/oauth 认证类型、模型格式自动识别、区域路由和 URL 构建。
前端新增 Key 认证类型选择和 Service Account 配置表单。

Co-authored-by: NyaDoo <65238336+NyaDoo@users.noreply.github.com>
Closes #194
This commit is contained in:
fawney19
2026-03-01 23:32:48 +08:00
parent a137601728
commit 4bf3a453e7
42 changed files with 1855 additions and 546 deletions

View File

@@ -0,0 +1,150 @@
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from src.core.vertex_auth import VertexAuthService
from src.services.model.upstream_fetcher import (
EndpointFetchConfig,
UpstreamModelsFetchContext,
fetch_models_for_key,
)
@pytest.mark.asyncio
async def test_fetch_models_for_key_vertex_api_key_custom_fetcher() -> None:
ctx = UpstreamModelsFetchContext(
provider_type="vertex_ai",
api_key_value="test-api-key",
format_to_endpoint={
"gemini:chat": EndpointFetchConfig(base_url="https://aiplatform.googleapis.com"),
},
proxy_config=None,
auth_config={},
)
mocked_models = [
{
"id": "gemini-2.5-pro",
"owned_by": "google",
"display_name": "Gemini 2.5 Pro",
"api_format": "gemini:chat",
}
]
with (
patch(
"src.services.provider.adapters.vertex_ai.plugin._fetch_models_from_url",
AsyncMock(return_value=(mocked_models, None, True)),
),
patch(
"src.services.proxy_node.resolver.build_proxy_client_kwargs",
return_value={"timeout": 1.0},
),
):
models, errors, ok, meta = await fetch_models_for_key(ctx, timeout_seconds=1.0)
assert ok is True
assert errors == []
assert meta is None
assert [m.get("id") for m in models] == ["gemini-2.5-pro"]
@pytest.mark.asyncio
async def test_fetch_models_for_key_vertex_service_account_ignores_soft_404_when_success() -> None:
auth_config = {
"project_id": "demo-project",
"client_email": "svc@example.iam.gserviceaccount.com",
"private_key": "-----BEGIN PRIVATE KEY-----\nTEST\n-----END PRIVATE KEY-----\n",
"region": "global",
}
ctx = UpstreamModelsFetchContext(
provider_type="vertex_ai",
api_key_value="__placeholder__",
format_to_endpoint={
"gemini:chat": EndpointFetchConfig(base_url="https://aiplatform.googleapis.com"),
"claude:chat": EndpointFetchConfig(base_url="https://aiplatform.googleapis.com"),
},
proxy_config=None,
auth_config=auth_config,
)
fetch_side_effect = [
(
[
{
"id": "gemini-2.0-flash",
"owned_by": "google",
"display_name": "Gemini 2.0 Flash",
"api_format": "gemini:chat",
}
],
None,
True,
),
([], "HTTP 404: not found", False),
]
with (
patch.object(
VertexAuthService,
"get_access_token",
AsyncMock(return_value="ya29.test-token"),
),
patch(
"src.services.provider.adapters.vertex_ai.plugin._iter_regions",
return_value=["global"],
),
patch(
"src.services.provider.adapters.vertex_ai.plugin._fetch_models_from_url",
AsyncMock(side_effect=fetch_side_effect),
),
patch(
"src.services.proxy_node.resolver.build_proxy_client_kwargs",
return_value={"timeout": 1.0},
),
):
models, errors, ok, meta = await fetch_models_for_key(ctx, timeout_seconds=1.0)
assert ok is True
assert errors == []
assert meta is None
ids = {m.get("id") for m in models}
assert "gemini-2.0-flash" in ids
@pytest.mark.asyncio
async def test_fetch_models_for_key_vertex_api_key_returns_soft_404_when_all_failed() -> None:
ctx = UpstreamModelsFetchContext(
provider_type="vertex_ai",
api_key_value="test-api-key",
format_to_endpoint={
"gemini:chat": EndpointFetchConfig(base_url="https://aiplatform.googleapis.com"),
},
proxy_config=None,
auth_config={},
)
with (
patch(
"src.services.provider.adapters.vertex_ai.plugin._fetch_models_from_url",
AsyncMock(
side_effect=[
([], "HTTP 404: not found", False),
([], "HTTP 404: not found", False),
]
),
),
patch(
"src.services.proxy_node.resolver.build_proxy_client_kwargs",
return_value={"timeout": 1.0},
),
):
models, errors, ok, meta = await fetch_models_for_key(ctx, timeout_seconds=1.0)
assert ok is False
assert models == []
assert meta is None
assert errors
assert "HTTP 404" in errors[0]