vertext-ai-proxy 0.2.0__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.

Potentially problematic release.


This version of vertext-ai-proxy might be problematic. Click here for more details.

Files changed (35) hide show
  1. vertext_ai_proxy/__init__.py +3 -0
  2. vertext_ai_proxy/app.py +143 -0
  3. vertext_ai_proxy/auth.py +10 -0
  4. vertext_ai_proxy/cli.py +158 -0
  5. vertext_ai_proxy/config.py +22 -0
  6. vertext_ai_proxy/core/__init__.py +1 -0
  7. vertext_ai_proxy/core/auth.py +122 -0
  8. vertext_ai_proxy/core/config.py +104 -0
  9. vertext_ai_proxy/core/exceptions.py +40 -0
  10. vertext_ai_proxy/core/security.py +49 -0
  11. vertext_ai_proxy/core/templates.py +40 -0
  12. vertext_ai_proxy/main.py +19 -0
  13. vertext_ai_proxy/models.py +19 -0
  14. vertext_ai_proxy/proxy.py +52 -0
  15. vertext_ai_proxy/routers/__init__.py +1 -0
  16. vertext_ai_proxy/routers/health.py +16 -0
  17. vertext_ai_proxy/routers/v1/__init__.py +1 -0
  18. vertext_ai_proxy/routers/v1/chat.py +15 -0
  19. vertext_ai_proxy/routers/v1/embeddings.py +17 -0
  20. vertext_ai_proxy/routers/v1/models.py +24 -0
  21. vertext_ai_proxy/schemas/__init__.py +1 -0
  22. vertext_ai_proxy/schemas/chat.py +18 -0
  23. vertext_ai_proxy/schemas/common.py +18 -0
  24. vertext_ai_proxy/schemas/embeddings.py +60 -0
  25. vertext_ai_proxy/schemas/models.py +17 -0
  26. vertext_ai_proxy/security.py +14 -0
  27. vertext_ai_proxy/services/__init__.py +1 -0
  28. vertext_ai_proxy/services/chat_service.py +118 -0
  29. vertext_ai_proxy/services/embedding_service.py +98 -0
  30. vertext_ai_proxy/services/model_service.py +128 -0
  31. vertext_ai_proxy/services/vertex_client.py +67 -0
  32. vertext_ai_proxy-0.2.0.dist-info/METADATA +214 -0
  33. vertext_ai_proxy-0.2.0.dist-info/RECORD +35 -0
  34. vertext_ai_proxy-0.2.0.dist-info/WHEEL +4 -0
  35. vertext_ai_proxy-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,3 @@
1
+ from vertext_ai_proxy.cli import main
2
+
3
+ __all__ = ["main"]
@@ -0,0 +1,143 @@
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.core.config import AppConfig, load_config, validate_config
12
+ from vertext_ai_proxy.core.exceptions import (
13
+ OpenAIAPIException,
14
+ openai_exception_handler,
15
+ )
16
+ from vertext_ai_proxy.core.security import APIKeyValidator
17
+ from vertext_ai_proxy.proxy import VertexAIProxy
18
+ from vertext_ai_proxy.routers.health import router as health_router
19
+ from vertext_ai_proxy.routers.v1.chat import router as chat_router
20
+ from vertext_ai_proxy.routers.v1.embeddings import router as embeddings_router
21
+ from vertext_ai_proxy.routers.v1.models import router as models_router
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def create_app(
27
+ config: AppConfig | None = None,
28
+ token_manager: GCPTokenManager | None = None,
29
+ http_client: httpx.AsyncClient | None = None,
30
+ ) -> FastAPI:
31
+ """FastAPI 应用工厂函数,支持依赖注入以便利单元测试。"""
32
+ active_config = config or load_config()
33
+
34
+ # 启动前强制执行配置安全校验(阻断示例占位 Key 和空 Key)
35
+ validate_config(active_config)
36
+
37
+ # 初始化 Token 管理器
38
+ active_token_manager = token_manager or GCPTokenManager(active_config.gcp)
39
+
40
+ created_client = False
41
+ effective_http_client = http_client
42
+ if effective_http_client is None:
43
+ proxy_url = active_config.proxy.http_proxy
44
+ effective_http_client = httpx.AsyncClient(
45
+ proxy=proxy_url if proxy_url else None,
46
+ follow_redirects=True,
47
+ )
48
+ created_client = True
49
+
50
+ proxy_instance = VertexAIProxy(
51
+ config=active_config,
52
+ token_manager=active_token_manager,
53
+ http_client=effective_http_client,
54
+ )
55
+
56
+ @asynccontextmanager
57
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
58
+ logger.info(
59
+ f"Vertex AI OpenAI Proxy started on http://{active_config.server.host}:{active_config.server.port} "
60
+ f"(GCP Project: {active_token_manager.project_id}, Location: {active_token_manager.location})"
61
+ )
62
+ try:
63
+ yield
64
+ finally:
65
+ if created_client and effective_http_client:
66
+ await effective_http_client.aclose()
67
+ logger.info("HTTP client closed.")
68
+
69
+ app = FastAPI(
70
+ title="Vertex AI OpenAI Proxy",
71
+ description="Lightweight reverse proxy translating Google Cloud Vertex AI OpenAPI to OpenAI format",
72
+ version="0.2.0",
73
+ lifespan=lifespan,
74
+ )
75
+
76
+ # 注册依赖服务与组件至 app.state
77
+ app.state.config = active_config
78
+ app.state.proxy = proxy_instance
79
+ app.state.client = proxy_instance.client
80
+ app.state.chat_service = proxy_instance.chat_service
81
+ app.state.embedding_service = proxy_instance.embedding_service
82
+ app.state.model_service = proxy_instance.model_service
83
+ app.state.api_key_validator = APIKeyValidator(active_config)
84
+
85
+ # 注册符合 OpenAI 标准的异常拦截器
86
+ app.add_exception_handler(OpenAIAPIException, openai_exception_handler)
87
+
88
+ @app.exception_handler(RequestValidationError)
89
+ async def validation_exception_handler(
90
+ request: Request, exc: RequestValidationError
91
+ ) -> JSONResponse:
92
+ errors = exc.errors()
93
+ err_msg = "; ".join([f"{err.get('loc', [])}: {err.get('msg')}" for err in errors])
94
+ return JSONResponse(
95
+ status_code=400,
96
+ content={
97
+ "error": {
98
+ "message": err_msg,
99
+ "type": "invalid_request_error",
100
+ "param": str(errors[0].get("loc", [None])[-1]) if errors else None,
101
+ "code": "invalid_parameter",
102
+ }
103
+ },
104
+ )
105
+
106
+ @app.exception_handler(Exception)
107
+ async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
108
+ logger.exception(f"Unhandled error processing {request.method} {request.url}: {exc}")
109
+ return JSONResponse(
110
+ status_code=500,
111
+ content={
112
+ "error": {
113
+ "message": f"Internal proxy error: {str(exc)}",
114
+ "type": "api_error",
115
+ "param": None,
116
+ "code": "internal_error",
117
+ }
118
+ },
119
+ )
120
+
121
+ # 挂载模块化路由
122
+ app.include_router(health_router)
123
+ app.include_router(models_router)
124
+ app.include_router(chat_router)
125
+ app.include_router(embeddings_router)
126
+
127
+ return app
128
+
129
+
130
+ def main() -> None:
131
+ """服务主入口命令行。"""
132
+ config = load_config()
133
+ app = create_app(config=config)
134
+ uvicorn.run(
135
+ app,
136
+ host=config.server.host,
137
+ port=config.server.port,
138
+ log_level=config.server.log_level.lower(),
139
+ )
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()
@@ -0,0 +1,10 @@
1
+ # Backward-compatibility re-exports
2
+ from vertext_ai_proxy.core.auth import ( # noqa: F401
3
+ CLOUD_PLATFORM_SCOPE,
4
+ GCPTokenManager,
5
+ )
6
+
7
+ __all__ = [
8
+ "CLOUD_PLATFORM_SCOPE",
9
+ "GCPTokenManager",
10
+ ]
@@ -0,0 +1,158 @@
1
+ import argparse
2
+ import logging
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ from vertext_ai_proxy.app import create_app
7
+ from vertext_ai_proxy.core.config import load_config, validate_config
8
+ from vertext_ai_proxy.core.templates import DEFAULT_CONFIG_TEMPLATE
9
+
10
+ __version__ = "0.2.0"
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