vertext-ai-proxy 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vertext_ai_proxy/__init__.py +4 -0
- vertext_ai_proxy/_version.py +16 -0
- vertext_ai_proxy/app.py +144 -0
- vertext_ai_proxy/auth.py +10 -0
- vertext_ai_proxy/cli.py +158 -0
- vertext_ai_proxy/config.py +22 -0
- vertext_ai_proxy/core/__init__.py +1 -0
- vertext_ai_proxy/core/auth.py +122 -0
- vertext_ai_proxy/core/config.py +104 -0
- vertext_ai_proxy/core/exceptions.py +40 -0
- vertext_ai_proxy/core/security.py +49 -0
- vertext_ai_proxy/core/templates.py +40 -0
- vertext_ai_proxy/main.py +19 -0
- vertext_ai_proxy/models.py +19 -0
- vertext_ai_proxy/proxy.py +52 -0
- vertext_ai_proxy/routers/__init__.py +1 -0
- vertext_ai_proxy/routers/health.py +16 -0
- vertext_ai_proxy/routers/v1/__init__.py +1 -0
- vertext_ai_proxy/routers/v1/chat.py +15 -0
- vertext_ai_proxy/routers/v1/embeddings.py +17 -0
- vertext_ai_proxy/routers/v1/models.py +24 -0
- vertext_ai_proxy/schemas/__init__.py +1 -0
- vertext_ai_proxy/schemas/chat.py +18 -0
- vertext_ai_proxy/schemas/common.py +18 -0
- vertext_ai_proxy/schemas/embeddings.py +60 -0
- vertext_ai_proxy/schemas/models.py +17 -0
- vertext_ai_proxy/security.py +14 -0
- vertext_ai_proxy/services/__init__.py +1 -0
- vertext_ai_proxy/services/chat_service.py +118 -0
- vertext_ai_proxy/services/embedding_service.py +98 -0
- vertext_ai_proxy/services/model_service.py +128 -0
- vertext_ai_proxy/services/vertex_client.py +67 -0
- vertext_ai_proxy-0.2.1.dist-info/METADATA +227 -0
- vertext_ai_proxy-0.2.1.dist-info/RECORD +36 -0
- vertext_ai_proxy-0.2.1.dist-info/WHEEL +4 -0
- vertext_ai_proxy-0.2.1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("vertext-ai-proxy")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
try:
|
|
9
|
+
pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
|
|
10
|
+
if pyproject_path.is_file():
|
|
11
|
+
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', pyproject_path.read_text(encoding="utf-8"))
|
|
12
|
+
__version__ = match.group(1) if match else "0.0.0.dev0"
|
|
13
|
+
else:
|
|
14
|
+
__version__ = "0.0.0.dev0"
|
|
15
|
+
except Exception:
|
|
16
|
+
__version__ = "0.0.0.dev0"
|
vertext_ai_proxy/app.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from collections.abc import AsyncGenerator
|
|
2
|
+
from contextlib import asynccontextmanager
|
|
3
|
+
import logging
|
|
4
|
+
from fastapi import FastAPI, Request
|
|
5
|
+
from fastapi.exceptions import RequestValidationError
|
|
6
|
+
from fastapi.responses import JSONResponse
|
|
7
|
+
import httpx
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from vertext_ai_proxy.core.auth import GCPTokenManager
|
|
11
|
+
from vertext_ai_proxy._version import __version__
|
|
12
|
+
from vertext_ai_proxy.core.config import AppConfig, load_config, validate_config
|
|
13
|
+
from vertext_ai_proxy.core.exceptions import (
|
|
14
|
+
OpenAIAPIException,
|
|
15
|
+
openai_exception_handler,
|
|
16
|
+
)
|
|
17
|
+
from vertext_ai_proxy.core.security import APIKeyValidator
|
|
18
|
+
from vertext_ai_proxy.proxy import VertexAIProxy
|
|
19
|
+
from vertext_ai_proxy.routers.health import router as health_router
|
|
20
|
+
from vertext_ai_proxy.routers.v1.chat import router as chat_router
|
|
21
|
+
from vertext_ai_proxy.routers.v1.embeddings import router as embeddings_router
|
|
22
|
+
from vertext_ai_proxy.routers.v1.models import router as models_router
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_app(
|
|
28
|
+
config: AppConfig | None = None,
|
|
29
|
+
token_manager: GCPTokenManager | None = None,
|
|
30
|
+
http_client: httpx.AsyncClient | None = None,
|
|
31
|
+
) -> FastAPI:
|
|
32
|
+
"""FastAPI 应用工厂函数,支持依赖注入以便利单元测试。"""
|
|
33
|
+
active_config = config or load_config()
|
|
34
|
+
|
|
35
|
+
# 启动前强制执行配置安全校验(阻断示例占位 Key 和空 Key)
|
|
36
|
+
validate_config(active_config)
|
|
37
|
+
|
|
38
|
+
# 初始化 Token 管理器
|
|
39
|
+
active_token_manager = token_manager or GCPTokenManager(active_config.gcp)
|
|
40
|
+
|
|
41
|
+
created_client = False
|
|
42
|
+
effective_http_client = http_client
|
|
43
|
+
if effective_http_client is None:
|
|
44
|
+
proxy_url = active_config.proxy.http_proxy
|
|
45
|
+
effective_http_client = httpx.AsyncClient(
|
|
46
|
+
proxy=proxy_url if proxy_url else None,
|
|
47
|
+
follow_redirects=True,
|
|
48
|
+
)
|
|
49
|
+
created_client = True
|
|
50
|
+
|
|
51
|
+
proxy_instance = VertexAIProxy(
|
|
52
|
+
config=active_config,
|
|
53
|
+
token_manager=active_token_manager,
|
|
54
|
+
http_client=effective_http_client,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@asynccontextmanager
|
|
58
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
59
|
+
logger.info(
|
|
60
|
+
f"Vertex AI OpenAI Proxy started on http://{active_config.server.host}:{active_config.server.port} "
|
|
61
|
+
f"(GCP Project: {active_token_manager.project_id}, Location: {active_token_manager.location})"
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
yield
|
|
65
|
+
finally:
|
|
66
|
+
if created_client and effective_http_client:
|
|
67
|
+
await effective_http_client.aclose()
|
|
68
|
+
logger.info("HTTP client closed.")
|
|
69
|
+
|
|
70
|
+
app = FastAPI(
|
|
71
|
+
title="Vertex AI OpenAI Proxy",
|
|
72
|
+
description="Lightweight reverse proxy translating Google Cloud Vertex AI OpenAPI to OpenAI format",
|
|
73
|
+
version=__version__,
|
|
74
|
+
lifespan=lifespan,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# 注册依赖服务与组件至 app.state
|
|
78
|
+
app.state.config = active_config
|
|
79
|
+
app.state.proxy = proxy_instance
|
|
80
|
+
app.state.client = proxy_instance.client
|
|
81
|
+
app.state.chat_service = proxy_instance.chat_service
|
|
82
|
+
app.state.embedding_service = proxy_instance.embedding_service
|
|
83
|
+
app.state.model_service = proxy_instance.model_service
|
|
84
|
+
app.state.api_key_validator = APIKeyValidator(active_config)
|
|
85
|
+
|
|
86
|
+
# 注册符合 OpenAI 标准的异常拦截器
|
|
87
|
+
app.add_exception_handler(OpenAIAPIException, openai_exception_handler)
|
|
88
|
+
|
|
89
|
+
@app.exception_handler(RequestValidationError)
|
|
90
|
+
async def validation_exception_handler(
|
|
91
|
+
request: Request, exc: RequestValidationError
|
|
92
|
+
) -> JSONResponse:
|
|
93
|
+
errors = exc.errors()
|
|
94
|
+
err_msg = "; ".join([f"{err.get('loc', [])}: {err.get('msg')}" for err in errors])
|
|
95
|
+
return JSONResponse(
|
|
96
|
+
status_code=400,
|
|
97
|
+
content={
|
|
98
|
+
"error": {
|
|
99
|
+
"message": err_msg,
|
|
100
|
+
"type": "invalid_request_error",
|
|
101
|
+
"param": str(errors[0].get("loc", [None])[-1]) if errors else None,
|
|
102
|
+
"code": "invalid_parameter",
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@app.exception_handler(Exception)
|
|
108
|
+
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
109
|
+
logger.exception(f"Unhandled error processing {request.method} {request.url}: {exc}")
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
status_code=500,
|
|
112
|
+
content={
|
|
113
|
+
"error": {
|
|
114
|
+
"message": f"Internal proxy error: {str(exc)}",
|
|
115
|
+
"type": "api_error",
|
|
116
|
+
"param": None,
|
|
117
|
+
"code": "internal_error",
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# 挂载模块化路由
|
|
123
|
+
app.include_router(health_router)
|
|
124
|
+
app.include_router(models_router)
|
|
125
|
+
app.include_router(chat_router)
|
|
126
|
+
app.include_router(embeddings_router)
|
|
127
|
+
|
|
128
|
+
return app
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main() -> None:
|
|
132
|
+
"""服务主入口命令行。"""
|
|
133
|
+
config = load_config()
|
|
134
|
+
app = create_app(config=config)
|
|
135
|
+
uvicorn.run(
|
|
136
|
+
app,
|
|
137
|
+
host=config.server.host,
|
|
138
|
+
port=config.server.port,
|
|
139
|
+
log_level=config.server.log_level.lower(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
main()
|
vertext_ai_proxy/auth.py
ADDED
vertext_ai_proxy/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from vertext_ai_proxy._version import __version__
|
|
7
|
+
from vertext_ai_proxy.app import create_app
|
|
8
|
+
from vertext_ai_proxy.core.config import load_config, validate_config
|
|
9
|
+
from vertext_ai_proxy.core.templates import DEFAULT_CONFIG_TEMPLATE
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("vertext_ai_proxy.cli")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle_init(args: argparse.Namespace) -> int:
|
|
15
|
+
"""处理 'vap init' 命令:在当前目录创建 config.local.yaml。"""
|
|
16
|
+
target = Path(args.output)
|
|
17
|
+
if target.exists() and not args.force:
|
|
18
|
+
print(
|
|
19
|
+
f"[WARN] '{target.name}' already exists at: {target.resolve()}\n"
|
|
20
|
+
f" To overwrite the existing file, run: vap init --force",
|
|
21
|
+
file=sys.stderr,
|
|
22
|
+
)
|
|
23
|
+
return 1
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
target.write_text(DEFAULT_CONFIG_TEMPLATE, encoding="utf-8")
|
|
27
|
+
print(
|
|
28
|
+
f"[SUCCESS] Initialized configuration file at:\n"
|
|
29
|
+
f" {target.resolve()}\n\n"
|
|
30
|
+
f"Next steps:\n"
|
|
31
|
+
f" 1. Edit '{target.name}':\n"
|
|
32
|
+
f" - proxy.api_keys: Replace placeholder with your secret key (e.g. sk-my-secret-key)\n"
|
|
33
|
+
f" - gcp.service_account_file: Path to your Google Cloud Service Account JSON file\n"
|
|
34
|
+
f" 2. Start the proxy server:\n"
|
|
35
|
+
f" vap run\n"
|
|
36
|
+
)
|
|
37
|
+
return 0
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"[ERROR] Failed to write '{target}': {e}", file=sys.stderr)
|
|
40
|
+
return 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def handle_run(args: argparse.Namespace) -> int:
|
|
44
|
+
"""处理 'vap run' 命令:加载配置并启动 Uvicorn 反代服务。"""
|
|
45
|
+
import uvicorn
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
config = load_config(args.config)
|
|
49
|
+
# 命令行参数覆盖
|
|
50
|
+
if args.host:
|
|
51
|
+
config.server.host = args.host
|
|
52
|
+
if args.port:
|
|
53
|
+
config.server.port = args.port
|
|
54
|
+
if args.log_level:
|
|
55
|
+
config.server.log_level = args.log_level
|
|
56
|
+
|
|
57
|
+
# 启动安全校验
|
|
58
|
+
validate_config(config)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
print(f"\n{e}\n", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
app = create_app(config=config)
|
|
64
|
+
uvicorn.run(
|
|
65
|
+
app,
|
|
66
|
+
host=config.server.host,
|
|
67
|
+
port=config.server.port,
|
|
68
|
+
log_level=config.server.log_level.lower(),
|
|
69
|
+
)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
74
|
+
"""构建 CLI 参数解析器。"""
|
|
75
|
+
parser = argparse.ArgumentParser(
|
|
76
|
+
prog="vap",
|
|
77
|
+
description="Vertex AI OpenAI Proxy (vap) - Lightweight OpenAI-compatible gateway for Google Vertex AI.",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"-v",
|
|
81
|
+
"--version",
|
|
82
|
+
action="version",
|
|
83
|
+
version=f"%(prog)s {__version__}",
|
|
84
|
+
help="Show program version and exit.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
subparsers = parser.add_subparsers(dest="subcommand", title="Commands")
|
|
88
|
+
|
|
89
|
+
# vap init
|
|
90
|
+
init_parser = subparsers.add_parser(
|
|
91
|
+
"init",
|
|
92
|
+
help="Initialize a new config.local.yaml in the current working directory.",
|
|
93
|
+
description="Scaffold config.local.yaml from default template in current directory.",
|
|
94
|
+
)
|
|
95
|
+
init_parser.add_argument(
|
|
96
|
+
"-f",
|
|
97
|
+
"--force",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Overwrite existing config.local.yaml if it already exists.",
|
|
100
|
+
)
|
|
101
|
+
init_parser.add_argument(
|
|
102
|
+
"-o",
|
|
103
|
+
"--output",
|
|
104
|
+
default="config.local.yaml",
|
|
105
|
+
help="Target output filename (default: config.local.yaml).",
|
|
106
|
+
)
|
|
107
|
+
init_parser.set_defaults(func=handle_init)
|
|
108
|
+
|
|
109
|
+
# vap run
|
|
110
|
+
run_parser = subparsers.add_parser(
|
|
111
|
+
"run",
|
|
112
|
+
help="Start the reverse proxy server.",
|
|
113
|
+
description="Run Vertex AI OpenAI Proxy server with Uvicorn.",
|
|
114
|
+
)
|
|
115
|
+
run_parser.add_argument(
|
|
116
|
+
"-c",
|
|
117
|
+
"--config",
|
|
118
|
+
default=None,
|
|
119
|
+
help="Path to YAML configuration file (default: searches config.local.yaml then config.yaml).",
|
|
120
|
+
)
|
|
121
|
+
run_parser.add_argument(
|
|
122
|
+
"-H",
|
|
123
|
+
"--host",
|
|
124
|
+
default=None,
|
|
125
|
+
help="Bind host (default: from config, e.g. 127.0.0.1).",
|
|
126
|
+
)
|
|
127
|
+
run_parser.add_argument(
|
|
128
|
+
"-p",
|
|
129
|
+
"--port",
|
|
130
|
+
type=int,
|
|
131
|
+
default=None,
|
|
132
|
+
help="Bind port (default: from config, e.g. 8766).",
|
|
133
|
+
)
|
|
134
|
+
run_parser.add_argument(
|
|
135
|
+
"--log-level",
|
|
136
|
+
default=None,
|
|
137
|
+
choices=["debug", "info", "warning", "error"],
|
|
138
|
+
help="Uvicorn log level (default: from config, e.g. info).",
|
|
139
|
+
)
|
|
140
|
+
run_parser.set_defaults(func=handle_run)
|
|
141
|
+
|
|
142
|
+
return parser
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv: list[str] | None = None) -> int:
|
|
146
|
+
"""CLI 主入口。"""
|
|
147
|
+
parser = build_parser()
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
|
|
150
|
+
if not args.subcommand:
|
|
151
|
+
parser.print_help()
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
return args.func(args)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
sys.exit(main())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Backward-compatibility re-exports
|
|
2
|
+
from vertext_ai_proxy.core.config import ( # noqa: F401
|
|
3
|
+
AppConfig,
|
|
4
|
+
DEFAULT_MODELS,
|
|
5
|
+
FORBIDDEN_PLACEHOLDER_KEYS,
|
|
6
|
+
GCPConfig,
|
|
7
|
+
ProxyConfig,
|
|
8
|
+
ServerConfig,
|
|
9
|
+
load_config,
|
|
10
|
+
validate_config,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AppConfig",
|
|
15
|
+
"DEFAULT_MODELS",
|
|
16
|
+
"FORBIDDEN_PLACEHOLDER_KEYS",
|
|
17
|
+
"GCPConfig",
|
|
18
|
+
"ProxyConfig",
|
|
19
|
+
"ServerConfig",
|
|
20
|
+
"load_config",
|
|
21
|
+
"validate_config",
|
|
22
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core infrastructure components: config, auth, security, and exceptions."""
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from google.auth.transport.requests import Request
|
|
8
|
+
from google.oauth2 import service_account
|
|
9
|
+
from vertext_ai_proxy.core.config import GCPConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GCPTokenManager:
|
|
17
|
+
"""管理 Google Cloud Service Account 凭据与 OAuth 2.0 Access Token 的生命周期。
|
|
18
|
+
|
|
19
|
+
- 支持原生 GCP 服务账号 JSON,以及带有外层封装(如 service_account 嵌套结构)的 JSON
|
|
20
|
+
- 自动提取 project_id(若未手动指定)
|
|
21
|
+
- 内存中缓存 Access Token,并在过期前通过线程池异步安全刷新
|
|
22
|
+
- 使用 asyncio.Lock 避免高并发下的重复刷新击穿
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
config: GCPConfig,
|
|
28
|
+
credentials: service_account.Credentials | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.config = config
|
|
31
|
+
self._lock = asyncio.Lock()
|
|
32
|
+
self._request = Request()
|
|
33
|
+
|
|
34
|
+
if credentials is not None:
|
|
35
|
+
self.credentials = credentials
|
|
36
|
+
else:
|
|
37
|
+
sa_path = Path(config.service_account_file)
|
|
38
|
+
if not sa_path.exists():
|
|
39
|
+
raise FileNotFoundError(
|
|
40
|
+
f"Google Cloud Service Account file not found at: {sa_path.resolve()}"
|
|
41
|
+
)
|
|
42
|
+
with open(sa_path, "r", encoding="utf-8") as f:
|
|
43
|
+
raw_data = json.load(f)
|
|
44
|
+
|
|
45
|
+
# 兼容两种常见格式:
|
|
46
|
+
# 1. 纯 Google 官方下载的 Service Account JSON
|
|
47
|
+
# 2. 外部工具封装格式(如 cli-proxy-api 格式,嵌套在 'service_account' 键下)
|
|
48
|
+
if (
|
|
49
|
+
isinstance(raw_data, dict)
|
|
50
|
+
and "service_account" in raw_data
|
|
51
|
+
and isinstance(raw_data["service_account"], dict)
|
|
52
|
+
):
|
|
53
|
+
logger.info("Detected wrapped service_account format, extracting inner credentials.")
|
|
54
|
+
sa_info = raw_data["service_account"]
|
|
55
|
+
# 若外层显式声明了 project_id 或 location,智能回退继承
|
|
56
|
+
if not self.config.project_id and raw_data.get("project_id"):
|
|
57
|
+
self.config.project_id = str(raw_data["project_id"])
|
|
58
|
+
if (not self.config.location or self.config.location == "global") and raw_data.get("location"):
|
|
59
|
+
self.config.location = str(raw_data["location"])
|
|
60
|
+
else:
|
|
61
|
+
sa_info = raw_data
|
|
62
|
+
|
|
63
|
+
self.credentials = service_account.Credentials.from_service_account_info(
|
|
64
|
+
sa_info,
|
|
65
|
+
scopes=[CLOUD_PLATFORM_SCOPE],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# 优先使用配置中显式指定的 project_id,否则从 Service Account JSON 中提取
|
|
69
|
+
self.project_id: str = (
|
|
70
|
+
self.config.project_id.strip()
|
|
71
|
+
if self.config.project_id and self.config.project_id.strip()
|
|
72
|
+
else (getattr(self.credentials, "project_id", "") or "")
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if not self.project_id:
|
|
76
|
+
logger.warning(
|
|
77
|
+
"No project_id could be determined from GCPConfig or credentials."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def location(self) -> str:
|
|
82
|
+
return self.config.location
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def base_upstream_url(self) -> str:
|
|
86
|
+
"""根据 location 和 project_id 构造 Vertex AI OpenAPI 的目标 Base URL。"""
|
|
87
|
+
# Google 统一端点路由
|
|
88
|
+
return (
|
|
89
|
+
f"https://aiplatform.googleapis.com/v1/projects/{self.project_id}"
|
|
90
|
+
f"/locations/{self.location}/endpoints/openapi"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _is_token_expiring(self, buffer_seconds: int = 300) -> bool:
|
|
94
|
+
"""检查 Token 是否已失效或即将失效(默认预留 5 分钟缓冲)。"""
|
|
95
|
+
if not self.credentials.token or not self.credentials.expiry:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
now = datetime.now(timezone.utc)
|
|
99
|
+
expiry = self.credentials.expiry
|
|
100
|
+
if expiry.tzinfo is None:
|
|
101
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
102
|
+
|
|
103
|
+
return (expiry - now).total_seconds() < buffer_seconds
|
|
104
|
+
|
|
105
|
+
def _refresh_sync(self) -> None:
|
|
106
|
+
"""同步刷新 Google OAuth Token(在线程池中调用)。"""
|
|
107
|
+
self.credentials.refresh(self._request)
|
|
108
|
+
|
|
109
|
+
async def get_access_token(self) -> str:
|
|
110
|
+
"""异步获取当前有效的 Access Token,必要时执行刷新。"""
|
|
111
|
+
if not self._is_token_expiring():
|
|
112
|
+
return self.credentials.token
|
|
113
|
+
|
|
114
|
+
async with self._lock:
|
|
115
|
+
# 双重检查锁(DCL)
|
|
116
|
+
if not self._is_token_expiring():
|
|
117
|
+
return self.credentials.token
|
|
118
|
+
|
|
119
|
+
logger.info("Google Cloud OAuth token expiring or absent; refreshing...")
|
|
120
|
+
await asyncio.to_thread(self._refresh_sync)
|
|
121
|
+
logger.info("Google Cloud OAuth token successfully refreshed.")
|
|
122
|
+
return self.credentials.token
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import yaml
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# 已知的示例或占位符 Key 集合
|
|
9
|
+
FORBIDDEN_PLACEHOLDER_KEYS: set[str] = {
|
|
10
|
+
"sk-change-this-to-your-own-secret-key",
|
|
11
|
+
"sk-vertex-local-test",
|
|
12
|
+
"sk-change-me",
|
|
13
|
+
"sk-example-key",
|
|
14
|
+
"sk-my-vertex-proxy-key",
|
|
15
|
+
"sk-placeholder",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
DEFAULT_MODELS: list[str] = [
|
|
19
|
+
"gemini-2.5-flash",
|
|
20
|
+
"gemini-2.5-pro",
|
|
21
|
+
"gemini-2.0-flash",
|
|
22
|
+
"gemini-2.0-flash-lite",
|
|
23
|
+
"gemini-1.5-pro",
|
|
24
|
+
"gemini-1.5-flash",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ServerConfig(BaseModel):
|
|
29
|
+
host: str = "127.0.0.1"
|
|
30
|
+
port: int = 8766
|
|
31
|
+
log_level: str = "info"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ProxyConfig(BaseModel):
|
|
35
|
+
api_keys: list[str] = Field(default_factory=list)
|
|
36
|
+
auto_prefix_model: bool = True
|
|
37
|
+
http_proxy: str | None = None
|
|
38
|
+
# 可在 yaml 配置文件中自由增减模型,供 /v1/models 接口返回给客户端
|
|
39
|
+
models: list[str] = Field(default_factory=lambda: list(DEFAULT_MODELS))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GCPConfig(BaseModel):
|
|
43
|
+
service_account_file: str = "service_account.json"
|
|
44
|
+
project_id: str = ""
|
|
45
|
+
location: str = "global"
|
|
46
|
+
timeout_seconds: int = 120
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AppConfig(BaseModel):
|
|
50
|
+
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
51
|
+
proxy: ProxyConfig = Field(default_factory=ProxyConfig)
|
|
52
|
+
gcp: GCPConfig = Field(default_factory=GCPConfig)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_config(config: AppConfig) -> None:
|
|
56
|
+
"""启动前强制进行安全性校验:
|
|
57
|
+
|
|
58
|
+
1. 校验 api_keys 是否非空
|
|
59
|
+
2. 校验是否仍在使用示例/占位 Key
|
|
60
|
+
"""
|
|
61
|
+
if not config.proxy.api_keys:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"[FATAL SECURITY ERROR] No API key configured in 'proxy.api_keys'. "
|
|
64
|
+
"Please configure at least one custom secret key in config.local.yaml."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
for raw_key in config.proxy.api_keys:
|
|
68
|
+
key = raw_key.strip()
|
|
69
|
+
if not key:
|
|
70
|
+
continue
|
|
71
|
+
if key in FORBIDDEN_PLACEHOLDER_KEYS or key.startswith("sk-change-this"):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"[FATAL SECURITY ERROR] Detected placeholder API Key: '{key}'. "
|
|
74
|
+
"The server refuses to start with default/example keys. "
|
|
75
|
+
"Please copy config.example.yaml to config.local.yaml and set your own secure API key."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_config(config_path: str | Path | None = None) -> AppConfig:
|
|
80
|
+
"""加载配置文件:
|
|
81
|
+
|
|
82
|
+
优先级:指定路径 -> config.local.yaml -> config.yaml -> 默认配置
|
|
83
|
+
"""
|
|
84
|
+
target_path: Path | None = None
|
|
85
|
+
|
|
86
|
+
if config_path:
|
|
87
|
+
target_path = Path(config_path)
|
|
88
|
+
else:
|
|
89
|
+
local_config = Path("config.local.yaml")
|
|
90
|
+
standard_config = Path("config.yaml")
|
|
91
|
+
|
|
92
|
+
if local_config.exists():
|
|
93
|
+
target_path = local_config
|
|
94
|
+
elif standard_config.exists():
|
|
95
|
+
target_path = standard_config
|
|
96
|
+
|
|
97
|
+
if target_path and target_path.exists():
|
|
98
|
+
logger.info(f"Loading configuration from {target_path}")
|
|
99
|
+
with open(target_path, "r", encoding="utf-8") as f:
|
|
100
|
+
data = yaml.safe_load(f) or {}
|
|
101
|
+
return AppConfig(**data)
|
|
102
|
+
|
|
103
|
+
logger.info("No configuration file found; initializing with default configuration.")
|
|
104
|
+
return AppConfig()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from fastapi import HTTPException, Request, status
|
|
2
|
+
from fastapi.responses import JSONResponse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OpenAIAPIException(HTTPException):
|
|
6
|
+
"""标准的 OpenAI 格式 API 异常。"""
|
|
7
|
+
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
status_code: int,
|
|
11
|
+
message: str,
|
|
12
|
+
code: str = "invalid_api_key",
|
|
13
|
+
param: str | None = None,
|
|
14
|
+
err_type: str = "invalid_request_error",
|
|
15
|
+
) -> None:
|
|
16
|
+
super().__init__(status_code=status_code, detail=message)
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.error_dict = {
|
|
19
|
+
"error": {
|
|
20
|
+
"message": message,
|
|
21
|
+
"type": err_type,
|
|
22
|
+
"param": param,
|
|
23
|
+
"code": code,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def openai_exception_handler(
|
|
29
|
+
request: Request, exc: OpenAIAPIException
|
|
30
|
+
) -> JSONResponse:
|
|
31
|
+
"""FastAPI 异常处理器:确保直接返回根级 {"error": {...}} 结构而非嵌套在 {"detail": ...} 中。"""
|
|
32
|
+
return JSONResponse(status_code=exc.status_code, content=exc.error_dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def raise_unauthorized(message: str = "Incorrect API key provided.") -> None:
|
|
36
|
+
raise OpenAIAPIException(
|
|
37
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
38
|
+
message=message,
|
|
39
|
+
code="invalid_api_key",
|
|
40
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
from fastapi import Header
|
|
3
|
+
from vertext_ai_proxy.core.config import AppConfig
|
|
4
|
+
from vertext_ai_proxy.core.exceptions import OpenAIAPIException, raise_unauthorized
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class APIKeyValidator:
|
|
8
|
+
"""验证客户端请求中的 API Key 是否在允许列表中。"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, config: AppConfig) -> None:
|
|
11
|
+
self.config = config
|
|
12
|
+
|
|
13
|
+
def __call__(
|
|
14
|
+
self,
|
|
15
|
+
authorization: str | None = Header(None),
|
|
16
|
+
api_key_header: str | None = Header(None, alias="api-key"),
|
|
17
|
+
) -> str:
|
|
18
|
+
token: str | None = None
|
|
19
|
+
|
|
20
|
+
if authorization:
|
|
21
|
+
parts = authorization.strip().split(" ", 1)
|
|
22
|
+
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
23
|
+
token = parts[1].strip()
|
|
24
|
+
else:
|
|
25
|
+
token = authorization.strip()
|
|
26
|
+
|
|
27
|
+
if not token and api_key_header:
|
|
28
|
+
token = api_key_header.strip()
|
|
29
|
+
|
|
30
|
+
if not token:
|
|
31
|
+
raise_unauthorized(
|
|
32
|
+
"Missing API key. Provide authorization header: 'Authorization: Bearer <key>' or 'api-key: <key>'."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
valid_keys = [k.strip() for k in self.config.proxy.api_keys if k.strip()]
|
|
36
|
+
if not valid_keys:
|
|
37
|
+
raise_unauthorized("No authorized API keys configured on proxy server.")
|
|
38
|
+
|
|
39
|
+
# 使用 secrets.compare_digest 防止时序攻击
|
|
40
|
+
matched = False
|
|
41
|
+
for valid_key in valid_keys:
|
|
42
|
+
if secrets.compare_digest(token, valid_key):
|
|
43
|
+
matched = True
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
if not matched:
|
|
47
|
+
raise_unauthorized("Invalid API key provided.")
|
|
48
|
+
|
|
49
|
+
return token
|