# API 密钥管理方案 本文档概述了 `airs_api_keys` 表的数据库存储、查询和代码建议方案,以支持 LLM 和搜索引擎 API 密钥的灵活管理。 ## 1. `airs_api_keys` 表的原始结构 ```sql create table public.airs_api_keys ( id bigint generated by default as identity not null, created_at timestamp with time zone not null default now(), api_key text null, ran_at timestamp without time zone null default now(), provider_id bigint null, constraint airs_api_keys_pkey primary key (id), constraint airs_api_keys_provider_id_fkey foreign KEY (provider_id) references airs_api_providers (id) ) TABLESPACE pg_default; ``` ## 2. `airs_api_keys` 表的建议修改结构 为了支持不同类型的 API 密钥(LLM 和搜索引擎)以及更灵活的查找机制,建议修改 `airs_api_keys` 表结构如下: ```sql create table public.airs_api_keys ( id bigint generated by default as identity not null, created_at timestamp with time zone not null default now(), api_key text null, ran_at timestamp without time zone null default now(), provider_id bigint null, api_type text not null, -- 'llm' or 'search' lookup_key text not null, -- LLM 的模型名称 (e.g., 'glm-4', 'gemini-pro'), 搜索引擎的域名或特定路径 (e.g., 'api.tavily.com') url_pattern text null, -- 可选: 用于验证或更精确匹配的 URL 模式 (e.g., 'https://open.bigmodel.cn/api/paas/v4/chat/completions') constraint airs_api_keys_pkey primary key (id), constraint airs_api_keys_provider_id_fkey foreign KEY (provider_id) references airs_api_providers (id), constraint airs_api_keys_unique_lookup_key unique (api_type, lookup_key) -- 确保每种类型和查找键组合的唯一性 ) TABLESPACE pg_default; ``` **字段说明:** * `api_type`: 字符串类型,用于区分 API 密钥的类型,例如 `'llm'` 或 `'search'`。 * `lookup_key`: 字符串类型,作为查询 API 密钥的关键字段。 * 对于 LLM 类 API,这将是请求体中 `model` 字段的值(例如 `glm-4`, `gemini-pro`)。 * 对于搜索引擎类 API,这将是其域名或特定路径(例如 `api.tavily.com`)。 * `url_pattern`: 可选的字符串类型,用于存储与 API 密钥关联的完整或部分 URL 模式。这可以用于在某些情况下进行额外的验证或更精确的匹配。 * `constraint airs_api_keys_unique_lookup_key`: 确保 `api_type` 和 `lookup_key` 的组合是唯一的,防止重复的 API 密钥配置。 ### 示例数据库记录 以下是一些 `airs_api_keys` 表中可能存在的示例记录: | id | created_at | api_key | ran_at | provider_id | api_type | lookup_key | url_pattern | | --- | ---------------------- | -------------------------- | ------------------- | ----------- | -------- | -------------- | ------------------------------------------------------------------------ | | 1 | 2023-01-01 10:00:00+00 | sk-xxxxxxxxxxxxxxxxxxxxglm | 2023-01-01 10:00:00 | 1 | llm | glm-4 | https://open.bigmodel.cn/api/paas/v4/chat/completions | | 2 | 2023-01-02 11:00:00+00 | AIzaSyBxxxxxxxxxxxxxxxxxx | 2023-01-02 11:00:00 | 2 | llm | gemini-pro | https://generativelanguage.googleapis.com/v1beta/openai/chat/completions | | 3 | 2023-01-03 12:00:00+00 | tvly-xxxxxxxxxxxxxxxxxxxx | 2023-01-03 12:00:00 | 3 | search | api.tavily.com | https://api.tavily.com/search | ## 3. 数据库查询方案 根据修改后的表结构,可以按 `api_type` 和 `lookup_key` 高效地查询 API 密钥。 ### 查询 LLM API 密钥 当代理服务收到 LLM 请求时,它会从请求体中提取 `model` 名称,并使用它作为 `lookup_key` 进行查询。 ```sql SELECT api_key, provider_id FROM public.airs_api_keys WHERE api_type = 'llm' AND lookup_key = :model_name; ``` * `:model_name` 将替换为从请求体中解析出的模型名称,例如 `'glm-4'` 或 `'gemini-pro'`。 ### 查询搜索引擎 API 密钥 当代理服务收到搜索引擎请求时,它会根据请求的 URL 识别出对应的域名或特定路径,并使用它作为 `lookup_key` 进行查询。 ```sql SELECT api_key, provider_id FROM public.airs_api_keys WHERE api_type = 'search' AND lookup_key = :domain_or_path; ``` * `:domain_or_path` 将替换为从请求 URL 中提取的域名或路径,例如 `'api.tavily.com'`。 ## 4. 代码建议方案 为了在 FastAPI 代理服务中实现 API 密钥的动态获取和注入,需要修改 `api_key_sb.py` 和 `proxy.py` 文件。 ### `api_key_sb.py` 中的 `get_api_key_info` 函数 修改 `get_api_key_info` 函数以接受 `api_type` 和 `lookup_value` 作为参数,并根据这些参数从 Supabase 查询 API 密钥。 ```python # api_key_sb.py from supabase import create_client, Client import os from dotenv import load_dotenv load_dotenv() SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_KEY = os.getenv("SUPABASE_KEY") supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) async def get_api_key_info(api_type: str, lookup_value: str): """ 根据 API 类型和查找值从 Supabase 获取 API 密钥信息。 """ try: response = supabase.from_('airs_api_keys').select('api_key, provider_id').eq('api_type', api_type).eq('lookup_key', lookup_value).limit(1).execute() if response.data: return response.data[0] return None except Exception as e: print(f"Error fetching API key info: {e}") return None ``` ### `proxy.py` 中的请求处理逻辑 在 `proxy.py` 中,需要修改请求处理逻辑以: 1. 识别传入请求的 API 类型(LLM 或搜索引擎)。 2. 根据 API 类型从请求中提取相应的 `lookup_value`(`model` 名称或域名/路径)。 3. 调用 `get_api_key_info` 获取 API 密钥。 4. 将获取到的 API 密钥注入到转发请求的相应位置(例如,请求头或请求体)。 以下是 `proxy.py` 中 `handle_proxy_request` 函数的伪代码片段: ```python # proxy.py from fastapi import FastAPI, Request, Response, HTTPException from fastapi.responses import StreamingResponse import httpx import json import logging import re from api_key_sb import get_api_key_info # 导入新的函数 # 配置日志 logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = FastAPI() client = httpx.AsyncClient() # 统一错误响应格式 def create_error_response(status_code: int, message: str): return Response( content=json.dumps({"error": {"message": message, "type": "proxy_error"}}), status_code=status_code, media_type="application/json" ) @app.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]) async def handle_proxy_request(request: Request, path: str): target_url_base = path # 假设 path 已经包含了目标 URL 的基础部分 api_key = None api_type = None lookup_value = None # 识别 API 类型并提取 lookup_value if "open.bigmodel.cn" in target_url_base or "generativelanguage.googleapis.com" in target_url_base: api_type = "llm" try: request_body = await request.json() lookup_value = request_body.get("model") if not lookup_value: logger.warning(f"LLM request missing 'model' in body for path: {path}") return create_error_response(400, "LLM request body must contain 'model' field.") except json.JSONDecodeError: logger.warning(f"LLM request body is not valid JSON for path: {path}") return create_error_response(400, "LLM request body must be valid JSON.") elif "api.tavily.com" in target_url_base: api_type = "search" lookup_value = "api.tavily.com" # 或者从 URL 中更动态地提取,例如使用正则表达式 else: logger.warning(f"Unknown API type for path: {path}") return create_error_response(400, "Unknown API type or unsupported target URL.") # 从数据库获取 API 密钥 if api_type and lookup_value: api_key_info = await get_api_key_info(api_type, lookup_value) if api_key_info: api_key = api_key_info["api_key"] logger.info(f"Successfully retrieved API key for type: {api_type}, lookup_value: {lookup_value}") else: logger.warning(f"API Key not found for type: {api_type}, lookup_value: {lookup_value}") return create_error_response(401, "API Key not found for the requested service.") else: logger.error(f"Failed to determine API type or lookup value for path: {path}") return create_error_response(500, "Internal server error: Could not determine API key parameters.") # 构建目标 URL # 假设原始请求的 path 已经包含了完整的后端服务 URL,例如 /v1/https/open.bigmodel.cn/api/paas/v4/chat/completions # 我们需要提取出 https://open.bigmodel.cn/api/paas/v4/chat/completions match = re.match(r"/v1/(https?://.*)", path) if not match: logger.error(f"Invalid target URL format in path: {path}") return create_error_response(400, "Invalid target URL format.") full_target_url = match.group(1) # 准备转发请求的头部和内容 headers = dict(request.headers) headers.pop("host", None) # 移除 host 头,避免后端服务解析错误 headers.pop("authorization", None) # 移除原始的 authorization 头,我们将注入新的 API 密钥 # 注入 API 密钥 if api_type == "llm": # 对于 LLM,通常 API 密钥在 Authorization 头中 headers["Authorization"] = f"Bearer {api_key}" elif api_type == "search": # 对于 Tavily 等搜索引擎,API 密钥可能在请求体中或自定义头中 # 这里假设 Tavily API 密钥在请求体中,需要修改请求体 # 注意:修改请求体需要重新构建请求,这可能比较复杂 # 更简单的做法是如果后端支持,通过自定义头传递 # 假设 Tavily API 密钥在请求体中,且请求体是 JSON if request.method == "POST": try: request_body = await request.json() request_body["api_key"] = api_key # 注入 Tavily API 密钥 request_content = json.dumps(request_body).encode("utf-8") headers["Content-Type"] = "application/json" except json.JSONDecodeError: logger.error(f"Search request body is not valid JSON for path: {path}") return create_error_response(400, "Search request body must be valid JSON.") else: # 如果是 GET 请求,API 密钥可能在查询参数中,这里需要根据实际情况调整 logger.warning(f"Search API key injection for GET request not implemented for path: {path}") return create_error_response(501, "Search API key injection for GET requests is not yet supported.") # 转发请求 try: req = client.build_request( method=request.method, url=full_target_url, headers=headers, content=request_content if 'request_content' in locals() else await request.body(), params=request.query_params, timeout=30.0 # 设置超时时间 ) proxy_response = await client.send(req, stream=True) return StreamingResponse( proxy_response.aiter_bytes(), status_code=proxy_response.status_code, headers=proxy_response.headers ) except httpx.RequestError as e: logger.error(f"HTTPX Request Error for {full_target_url}: {e}") return create_error_response(500, f"Proxy request failed: {e}") except Exception as e: logger.error(f"An unexpected error occurred: {e}") return create_error_response(500, "An unexpected error occurred during proxying.") ```