Skip to content

Commit c7b626d

Browse files
niiish32xnishenghao.nsh
andauthored
⏺ feat: optimize LLM API Key configuration flow (#169)
Co-authored-by: nishenghao.nsh <nishenghao.nsh@oceanbase.com>
1 parent 0c009d7 commit c7b626d

5 files changed

Lines changed: 565 additions & 16 deletions

File tree

packages/derisk-app/src/derisk_app/openapi/api_v1/config_api.py

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ class SecretRequest(BaseModel):
5656
description: Optional[str] = None
5757

5858

59+
class LLMKeyRequest(BaseModel):
60+
provider: str
61+
api_key: str
62+
63+
64+
class LLMKeyStatus(BaseModel):
65+
provider: str
66+
description: str
67+
is_configured: bool
68+
69+
5970
_config_manager = None
6071

6172

@@ -599,9 +610,12 @@ async def list_secrets():
599610

600611
default_secrets = {
601612
"master_encrypt_key": "主加密密钥,用于加密其他敏感数据",
602-
"openai_api_key": "OpenAI API Key",
603-
"dashscope_api_key": "阿里云 DashScope API Key",
604-
"anthropic_api_key": "Anthropic API Key",
613+
"openai_api_key": "OpenAI API Key (系统设置 - LLM)",
614+
"dashscope_api_key": "阿里云 DashScope API Key (系统设置 - LLM)",
615+
"alibaba_api_key": "阿里云 API Key (系统设置 - LLM)",
616+
"anthropic_api_key": "Anthropic API Key (系统设置 - LLM)",
617+
"claude_api_key": "Claude API Key (系统设置 - LLM)",
618+
"llm_api_key": "通用 LLM API Key (系统设置 - LLM)",
605619
"oss_access_key_id": "阿里云 OSS Access Key ID",
606620
"oss_access_key_secret": "阿里云 OSS Access Key Secret",
607621
"db_password": "数据库密码",
@@ -710,6 +724,143 @@ async def export_config_safe():
710724
)
711725

712726

727+
@router.get("/llm-keys")
728+
async def list_llm_keys():
729+
"""获取 LLM Key 配置状态列表
730+
731+
只返回是否已配置的状态,不返回实际的 key 值
732+
"""
733+
from derisk_core.config.encryption import list_secrets as get_secrets_status
734+
735+
secrets_status = get_secrets_status()
736+
737+
# 定义支持的 LLM Provider 及其默认描述(简化为最常用的两个)
738+
llm_providers = {
739+
"openai": "OpenAI API Key (GPT 系列模型)",
740+
"alibaba": "阿里云 DashScope API Key (通义千问/Qwen/DeepSeek 等)",
741+
}
742+
743+
# 定义各个 provider 对应的 secrets key 名称
744+
provider_secret_keys = {
745+
"openai": ["openai_api_key"],
746+
"alibaba": ["dashscope_api_key"],
747+
}
748+
749+
llm_keys = []
750+
for provider, description in llm_providers.items():
751+
secret_keys = provider_secret_keys.get(provider, [])
752+
# 检查该 provider 是否有任意一个对应的 secret key 已配置
753+
is_configured = any(
754+
secrets_status.get(key, False) for key in secret_keys
755+
)
756+
757+
llm_keys.append({
758+
"provider": provider,
759+
"description": description,
760+
"is_configured": is_configured,
761+
})
762+
763+
return JSONResponse(
764+
content={
765+
"success": True,
766+
"data": llm_keys,
767+
"note": "只返回配置状态,不返回实际的 key 值",
768+
}
769+
)
770+
771+
772+
@router.post("/llm-keys")
773+
async def set_llm_key(request: LLMKeyRequest):
774+
"""设置 LLM API Key
775+
776+
将 API Key 加密存储到 secrets 中,配置后立即生效
777+
"""
778+
from derisk_core.config.encryption import set_secret
779+
import logging
780+
781+
logger = logging.getLogger(__name__)
782+
783+
try:
784+
# 根据 provider 确定存储的 key 名称(简化为只支持 openai 和 alibaba)
785+
provider = request.provider.lower()
786+
787+
secret_key_mapping = {
788+
"openai": "openai_api_key",
789+
"alibaba": "dashscope_api_key",
790+
}
791+
792+
if provider not in secret_key_mapping:
793+
raise HTTPException(status_code=400, detail=f"不支持的 provider: {provider}")
794+
795+
secret_name = secret_key_mapping.get(provider)
796+
797+
# 清理 API Key(去除前后空格)
798+
api_key = request.api_key.strip() if request.api_key else ""
799+
800+
if not api_key:
801+
raise HTTPException(status_code=400, detail="API Key 不能为空")
802+
803+
# 记录调试信息(隐藏部分 key)
804+
key_preview = f"{api_key[:8]}...{api_key[-4:]}" if len(api_key) > 12 else "***"
805+
logger.info(f"Saving API key for provider={provider}, secret_name={secret_name}, key_preview={key_preview}, key_length={len(api_key)}")
806+
807+
# 加密存储 API Key
808+
success = set_secret(secret_name, api_key)
809+
810+
if success:
811+
return JSONResponse(
812+
content={
813+
"success": True,
814+
"message": f"{provider} API Key 已加密存储",
815+
"provider": provider,
816+
"secret_name": secret_name,
817+
"note": "配置已生效,新的请求将使用此 API Key",
818+
}
819+
)
820+
else:
821+
raise HTTPException(status_code=500, detail="保存 API Key 失败")
822+
except Exception as e:
823+
logger.error(f"Failed to save LLM key: {e}")
824+
raise HTTPException(status_code=400, detail=str(e))
825+
826+
827+
@router.delete("/llm-keys/{provider}")
828+
async def delete_llm_key(provider: str):
829+
"""删除 LLM API Key 配置
830+
831+
删除后,系统将回退到使用配置文件中的 API Key
832+
"""
833+
from derisk_core.config.encryption import delete_secret
834+
835+
try:
836+
provider = provider.lower()
837+
838+
secret_key_mapping = {
839+
"openai": "openai_api_key",
840+
"alibaba": "dashscope_api_key",
841+
}
842+
843+
if provider not in secret_key_mapping:
844+
raise HTTPException(status_code=400, detail=f"不支持的 provider: {provider}")
845+
846+
secret_name = secret_key_mapping.get(provider)
847+
848+
success = delete_secret(secret_name)
849+
850+
if success:
851+
return JSONResponse(
852+
content={
853+
"success": True,
854+
"message": f"{provider} API Key 已删除",
855+
"note": "已删除系统设置中的 API Key,将回退到使用配置文件中的配置",
856+
}
857+
)
858+
else:
859+
raise HTTPException(status_code=500, detail="删除 API Key 失败")
860+
except Exception as e:
861+
raise HTTPException(status_code=400, detail=str(e))
862+
863+
713864
@router.get("/system")
714865
async def get_system_config():
715866
manager = get_config_manager()

packages/derisk-core/src/derisk/agent/util/llm/llm_client.py

Lines changed: 130 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,74 @@
2121
logger = logging.getLogger(__name__)
2222

2323

24+
def _get_api_key_from_secrets(provider_name: str, base_url: Optional[str] = None) -> Optional[str]:
25+
"""从加密存储的 secrets 中获取 API Key
26+
27+
优先级:
28+
1. 先尝试获取特定于 provider 的 key (如 openai_api_key, dashscope_api_key)
29+
2. 根据 base_url 判断是否使用特定平台的 key
30+
3. 如果没有,尝试通用的 llm_api_key
31+
32+
Args:
33+
provider_name: Provider 名称 (如 openai, alibaba, anthropic)
34+
base_url: API base URL,用于判断实际使用的平台
35+
36+
Returns:
37+
API Key 或 None
38+
"""
39+
try:
40+
from derisk_core.config.encryption import get_secret
41+
42+
provider_name_lower = provider_name.lower()
43+
44+
# 首先尝试获取 provider 特定的 key
45+
provider_specific_keys = {
46+
"openai": ["openai_api_key"],
47+
"alibaba": ["dashscope_api_key", "alibaba_api_key"],
48+
"anthropic": ["anthropic_api_key", "claude_api_key"],
49+
"dashscope": ["dashscope_api_key"],
50+
"claude": ["anthropic_api_key", "claude_api_key"],
51+
}
52+
53+
# 根据 base_url 判断实际使用的平台(处理 OpenAI 兼容模式)
54+
base_url_lower = base_url.lower() if base_url else ""
55+
if "dashscope" in base_url_lower or "aliyun" in base_url_lower:
56+
# 阿里云 DashScope 使用 OpenAI 兼容 API,但实际 key 是 dashscope_api_key
57+
keys_to_try = ["dashscope_api_key", "alibaba_api_key", "llm_api_key"]
58+
elif "anthropic" in base_url_lower or "claude" in base_url_lower:
59+
keys_to_try = ["anthropic_api_key", "claude_api_key", "llm_api_key"]
60+
elif "openai" in base_url_lower or "openai.com" in base_url_lower:
61+
keys_to_try = ["openai_api_key", "llm_api_key"]
62+
else:
63+
# 使用默认的 provider 映射
64+
keys_to_try = provider_specific_keys.get(provider_name_lower, [])
65+
if provider_name_lower == "openai":
66+
# openai provider 可能是 OpenAI 也可能是其他 OpenAI 兼容服务
67+
# 尝试所有可能的 key
68+
keys_to_try = ["openai_api_key", "dashscope_api_key", "alibaba_api_key", "anthropic_api_key"]
69+
70+
# 最后添加通用的 llm_api_key
71+
if "llm_api_key" not in keys_to_try:
72+
keys_to_try.append("llm_api_key")
73+
74+
logger.debug(f"Looking for API key: provider={provider_name}, base_url={base_url}, keys_to_try={keys_to_try}")
75+
76+
for key_name in keys_to_try:
77+
secret_value = get_secret(key_name)
78+
if secret_value:
79+
# 记录找到的 key(只显示部分信息)
80+
key_preview = f"{secret_value[:8]}...{secret_value[-4:]}" if len(secret_value) > 12 else "***"
81+
logger.info(
82+
f"Found API key from secrets: key_name={key_name}, provider={provider_name}, preview={key_preview}, length={len(secret_value)}")
83+
return secret_value
84+
85+
logger.debug(f"No API key found in secrets for provider={provider_name}")
86+
return None
87+
except Exception as e:
88+
logger.warning(f"Failed to get API key from secrets: {e}")
89+
return None
90+
91+
2492
class AgentLLMOut(BaseModel):
2593
llm_name: Optional[str] = None
2694
llm_context: Optional[dict] = None
@@ -58,10 +126,10 @@ class AIWrapper:
58126
}
59127

60128
def __init__(
61-
self,
62-
llm_client: Optional[LLMClient] = None,
63-
llm_config: Optional[AgentLLMConfig] = None,
64-
output_parser: Optional[BaseOutputParser] = None,
129+
self,
130+
llm_client: Optional[LLMClient] = None,
131+
llm_config: Optional[AgentLLMConfig] = None,
132+
output_parser: Optional[BaseOutputParser] = None,
65133
):
66134
"""Create an AIWrapper instance.
67135
@@ -89,7 +157,34 @@ def _init_provider(self):
89157
api_key = self._llm_config.api_key
90158
base_url = self._llm_config.base_url
91159

160+
# 检查 api_key 是否是占位符(未解析的配置引用或默认值)
161+
def _is_placeholder_key(key: Optional[str]) -> bool:
162+
if not key:
163+
return True
164+
# 检查是否是 ${env:xxx} 或 ${secrets.xxx} 格式
165+
if key.startswith("${"):
166+
return True
167+
# 检查是否是常见的占位符值
168+
placeholder_patterns = ["sk-...", "sk-xxxx", "your_api_key", "xxx", "placeholder"]
169+
key_lower = key.lower()
170+
if any(pattern in key_lower for pattern in placeholder_patterns):
171+
return True
172+
return False
173+
174+
is_placeholder = _is_placeholder_key(api_key)
175+
if is_placeholder and api_key:
176+
logger.debug(f"API key appears to be a placeholder: {api_key[:20]}..., will try to get from secrets")
177+
178+
# 优先级:系统设置(secrets) > 配置文件 > 环境变量
179+
if not api_key or is_placeholder:
180+
# 1. 首先尝试从加密存储的 secrets 中获取
181+
secrets_key = _get_api_key_from_secrets(provider_name, base_url)
182+
if secrets_key:
183+
api_key = secrets_key
184+
logger.info(f"Using API key from system secrets for provider={provider_name}")
185+
92186
if not api_key:
187+
# 2. 然后尝试从环境变量获取
93188
env_key = ProviderRegistry.get_env_key(provider_name)
94189
if env_key:
95190
api_key = os.getenv(env_key)
@@ -111,7 +206,7 @@ def _init_provider(self):
111206
model=self._llm_config.model,
112207
**kwargs,
113208
)
114-
209+
115210
if provider:
116211
self._provider = provider
117212
else:
@@ -172,9 +267,35 @@ async def create(self, **config):
172267
try:
173268
temp_llm_config = AgentLLMConfig.from_dict(model_config_dict)
174269
provider_name = temp_llm_config.provider.lower()
175-
api_key = temp_llm_config.api_key
270+
176271
base_url = temp_llm_config.base_url
177272

273+
# 检查 api_key 是否是占位符
274+
def _is_placeholder_key(key: Optional[str]) -> bool:
275+
if not key:
276+
return True
277+
if key.startswith("${"):
278+
return True
279+
placeholder_patterns = ["sk-...", "sk-xxxx", "your_api_key", "xxx", "placeholder"]
280+
key_lower = key.lower()
281+
if any(pattern in key_lower for pattern in placeholder_patterns):
282+
return True
283+
return False
284+
285+
api_key = temp_llm_config.api_key
286+
is_placeholder = _is_placeholder_key(api_key)
287+
288+
# 优先级:系统设置(secrets) > 配置文件 > 环境变量
289+
if not api_key or is_placeholder:
290+
api_key = _get_api_key_from_secrets(provider_name, base_url)
291+
if api_key:
292+
logger.info(
293+
f"Using API key from system secrets for model={llm_model}, provider={provider_name}")
294+
if not api_key:
295+
env_key = ProviderRegistry.get_env_key(provider_name)
296+
if env_key:
297+
api_key = os.getenv(env_key)
298+
178299
provider = ProviderRegistry.create_provider(
179300
name=provider_name,
180301
api_key=api_key or "",
@@ -253,9 +374,9 @@ async def create(self, **config):
253374
content = msg.content if hasattr(msg, "content") else str(msg)
254375
if isinstance(content, list):
255376
content_str = (
256-
"["
257-
+ ", ".join([f"{c.get('type', 'unknown')}" for c in content])
258-
+ "]"
377+
"["
378+
+ ", ".join([f"{c.get('type', 'unknown')}" for c in content])
379+
+ "]"
259380
)
260381
else:
261382
content_str = (

packages/derisk-core/src/derisk_core/config/encryption.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,12 @@ def decrypt(self, ciphertext: str) -> str:
127127
key = self._derive_key(self.get_master_key())
128128
f = self._fernet(key)
129129
encrypted = ciphertext[4:].encode()
130-
return f.decrypt(encrypted).decode()
130+
decrypted = f.decrypt(encrypted).decode()
131+
logger.debug(f"Successfully decrypted data, length={len(decrypted)}")
132+
return decrypted
131133
except Exception as e:
132-
logger.error(f"Decryption failed: {e}")
134+
logger.error(f"Decryption failed: {e}, ciphertext_prefix={ciphertext[:20] if ciphertext else 'empty'}...")
135+
# 返回空字符串表示解密失败
133136
return ""
134137

135138

0 commit comments

Comments
 (0)