api-key-manager 5.0.1__tar.gz → 5.0.2__tar.gz

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.
Files changed (125) hide show
  1. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/PKG-INFO +4 -4
  2. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/README.md +3 -3
  3. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/api_key_manager.egg-info/PKG-INFO +4 -4
  4. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/__init__.py +1 -1
  5. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/config.py +23 -0
  6. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/logger.py +18 -6
  7. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/storage.py +56 -6
  8. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/_app.py +24 -11
  9. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/middleware.py +16 -11
  10. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/balance.py +2 -2
  11. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/check.py +6 -6
  12. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/keys.py +8 -8
  13. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/misc.py +4 -4
  14. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/test.py +3 -3
  15. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/pyproject.toml +1 -1
  16. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_bug_fixes.py +36 -0
  17. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_logger.py +19 -0
  18. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_routes_misc.py +18 -10
  19. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_storage.py +32 -4
  20. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/LICENSE +0 -0
  21. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/api_key_manager.egg-info/SOURCES.txt +0 -0
  22. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/api_key_manager.egg-info/dependency_links.txt +0 -0
  23. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/api_key_manager.egg-info/entry_points.txt +0 -0
  24. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/api_key_manager.egg-info/requires.txt +0 -0
  25. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/api_key_manager.egg-info/top_level.txt +0 -0
  26. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/__main__.py +0 -0
  27. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/api_models.py +0 -0
  28. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/checker.py +0 -0
  29. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/cli.py +0 -0
  30. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/core.py +0 -0
  31. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/detector.py +0 -0
  32. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/errors.py +0 -0
  33. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/i18n.py +0 -0
  34. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/model_capabilities.py +0 -0
  35. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/parser.py +0 -0
  36. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/__init__.py +0 -0
  37. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/ai302.py +0 -0
  38. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/anthropic.py +0 -0
  39. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/baichuan.py +0 -0
  40. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/base.py +0 -0
  41. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/cerebras.py +0 -0
  42. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/cohere.py +0 -0
  43. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/config_providers.py +0 -0
  44. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/cstcloud.py +0 -0
  45. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/dashscope.py +0 -0
  46. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/dashscope_coding.py +0 -0
  47. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/deepseek.py +0 -0
  48. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/dmxapi.py +0 -0
  49. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/doubao.py +0 -0
  50. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/fireworks.py +0 -0
  51. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/google.py +0 -0
  52. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/grok.py +0 -0
  53. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/groq.py +0 -0
  54. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/huggingface.py +0 -0
  55. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/hyperbolic.py +0 -0
  56. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/infini.py +0 -0
  57. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/infini_coding.py +0 -0
  58. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/kimi.py +0 -0
  59. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/kimi_coding.py +0 -0
  60. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/longcat.py +0 -0
  61. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/mimo.py +0 -0
  62. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/mimo_plan.py +0 -0
  63. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/minimax.py +0 -0
  64. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/minimax_plan.py +0 -0
  65. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/mistral.py +0 -0
  66. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/models_registry.py +0 -0
  67. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/modelscope.py +0 -0
  68. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/nvidia.py +0 -0
  69. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/ocoolai.py +0 -0
  70. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/openai.py +0 -0
  71. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/opencode.py +0 -0
  72. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/opencode_zen.py +0 -0
  73. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/openrouter.py +0 -0
  74. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/perplexity.py +0 -0
  75. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/poe.py +0 -0
  76. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/ppio.py +0 -0
  77. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/replicate.py +0 -0
  78. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/siliconflow.py +0 -0
  79. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/stepfun.py +0 -0
  80. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/tencent_hunyuan.py +0 -0
  81. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/together.py +0 -0
  82. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/yi.py +0 -0
  83. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/zai.py +0 -0
  84. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/zhipu.py +0 -0
  85. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/providers/zhipu_coding.py +0 -0
  86. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/proxy.py +0 -0
  87. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/py.typed +0 -0
  88. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/ssrf.py +0 -0
  89. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/tester.py +0 -0
  90. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/url_override.py +0 -0
  91. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/validator.py +0 -0
  92. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/__init__.py +0 -0
  93. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/progress.py +0 -0
  94. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/__init__.py +0 -0
  95. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/models.py +0 -0
  96. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/providers.py +0 -0
  97. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/routes/stats.py +0 -0
  98. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/web/utils.py +0 -0
  99. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/key_manager/webhook.py +0 -0
  100. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/setup.cfg +0 -0
  101. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_api_endpoints.py +0 -0
  102. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_base_check.py +0 -0
  103. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_checker.py +0 -0
  104. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_core.py +0 -0
  105. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_deepseek_balance.py +0 -0
  106. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_detector_unit.py +0 -0
  107. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_e2e.py +0 -0
  108. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_errors.py +0 -0
  109. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_i18n.py +0 -0
  110. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_openapi.py +0 -0
  111. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_parser.py +0 -0
  112. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_provider_detection.py +0 -0
  113. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_provider_refactoring.py +0 -0
  114. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_providers.py +0 -0
  115. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_proxy.py +0 -0
  116. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_routes_check.py +0 -0
  117. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_routes_models.py +0 -0
  118. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_routes_providers.py +0 -0
  119. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_routes_test.py +0 -0
  120. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_security.py +0 -0
  121. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_tester.py +0 -0
  122. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_token_limit_fast.py +0 -0
  123. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_v320_changes.py +0 -0
  124. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_validator.py +0 -0
  125. {api_key_manager-5.0.1 → api_key_manager-5.0.2}/tests/test_webhook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-key-manager
3
- Version: 5.0.1
3
+ Version: 5.0.2
4
4
  Summary: Batch manage API keys for 45+ AI providers with CLI and Web interfaces
5
5
  Author: Townrain
6
6
  License: MIT
@@ -62,14 +62,14 @@ Dynamic: license-file
62
62
  - **API 文档** - Swagger UI 和 Redoc 自动文档
63
63
  - **国际化** - 支持中英文错误信息
64
64
  - **SDK 支持** - Python 和 TypeScript 客户端库
65
- - **桌面应用** - Tauri v2.5 原生桌面应用(v5.0.0 新增),Windows / macOS / Linux
65
+ - **桌面应用** - exe 便携安装,React 前端 + pywebview 桌面窗口
66
66
  - **Webhook 通知** - 事件驱动的 Webhook 通知系统
67
67
 
68
68
  ## 快速开始
69
69
 
70
- ### 桌面应用(v5.0.0
70
+ ### 桌面应用(v5.0.2
71
71
 
72
- 从 [Releases](https://github.com/Townrain/API-Key-Manager/releases) 下载对应平台的安装包,解压后运行 `keyhub-desktop.exe`。
72
+ 从 [Releases](https://github.com/Townrain/API-Key-Manager/releases) 下载 `KeyHub-Setup.exe`,选择任意目录安装即可。首次启动自动生成配置文件,所有数据保持在安装目录内。
73
73
 
74
74
  ### CLI / Web
75
75
 
@@ -22,14 +22,14 @@
22
22
  - **API 文档** - Swagger UI 和 Redoc 自动文档
23
23
  - **国际化** - 支持中英文错误信息
24
24
  - **SDK 支持** - Python 和 TypeScript 客户端库
25
- - **桌面应用** - Tauri v2.5 原生桌面应用(v5.0.0 新增),Windows / macOS / Linux
25
+ - **桌面应用** - exe 便携安装,React 前端 + pywebview 桌面窗口
26
26
  - **Webhook 通知** - 事件驱动的 Webhook 通知系统
27
27
 
28
28
  ## 快速开始
29
29
 
30
- ### 桌面应用(v5.0.0
30
+ ### 桌面应用(v5.0.2
31
31
 
32
- 从 [Releases](https://github.com/Townrain/API-Key-Manager/releases) 下载对应平台的安装包,解压后运行 `keyhub-desktop.exe`。
32
+ 从 [Releases](https://github.com/Townrain/API-Key-Manager/releases) 下载 `KeyHub-Setup.exe`,选择任意目录安装即可。首次启动自动生成配置文件,所有数据保持在安装目录内。
33
33
 
34
34
  ### CLI / Web
35
35
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-key-manager
3
- Version: 5.0.1
3
+ Version: 5.0.2
4
4
  Summary: Batch manage API keys for 45+ AI providers with CLI and Web interfaces
5
5
  Author: Townrain
6
6
  License: MIT
@@ -62,14 +62,14 @@ Dynamic: license-file
62
62
  - **API 文档** - Swagger UI 和 Redoc 自动文档
63
63
  - **国际化** - 支持中英文错误信息
64
64
  - **SDK 支持** - Python 和 TypeScript 客户端库
65
- - **桌面应用** - Tauri v2.5 原生桌面应用(v5.0.0 新增),Windows / macOS / Linux
65
+ - **桌面应用** - exe 便携安装,React 前端 + pywebview 桌面窗口
66
66
  - **Webhook 通知** - 事件驱动的 Webhook 通知系统
67
67
 
68
68
  ## 快速开始
69
69
 
70
- ### 桌面应用(v5.0.0
70
+ ### 桌面应用(v5.0.2
71
71
 
72
- 从 [Releases](https://github.com/Townrain/API-Key-Manager/releases) 下载对应平台的安装包,解压后运行 `keyhub-desktop.exe`。
72
+ 从 [Releases](https://github.com/Townrain/API-Key-Manager/releases) 下载 `KeyHub-Setup.exe`,选择任意目录安装即可。首次启动自动生成配置文件,所有数据保持在安装目录内。
73
73
 
74
74
  ### CLI / Web
75
75
 
@@ -13,5 +13,5 @@ __all__ = [
13
13
  "KeyStore",
14
14
  ]
15
15
 
16
- __version__ = "5.0.1"
16
+ __version__ = "5.0.2"
17
17
 
@@ -1,5 +1,6 @@
1
1
  import copy
2
2
  import logging
3
+ import sys
3
4
  from pathlib import Path
4
5
 
5
6
  import yaml
@@ -77,8 +78,30 @@ def _validate_config(config: dict) -> dict:
77
78
  return config
78
79
 
79
80
 
81
+ def _copy_example_config(config_path: Path):
82
+ """Copy bundled config.yaml.example to config.yaml on first run."""
83
+ if getattr(sys, "frozen", False):
84
+ sample = Path(sys._MEIPASS) / "config.yaml.example"
85
+ else:
86
+ sample = Path("config.yaml.example")
87
+ if sample.exists():
88
+ try:
89
+ config_path.write_bytes(sample.read_bytes())
90
+ logger.info(f"Created default config at {config_path}")
91
+ except OSError:
92
+ pass
93
+
94
+
80
95
  def load_config(path: str = "config.yaml") -> dict:
96
+ # PyInstaller exe: resolve path next to the exe, auto-create from example
97
+ if getattr(sys, "frozen", False):
98
+ exe_dir = Path(sys.argv[0]).parent
99
+ path = str(exe_dir / "config.yaml")
100
+
81
101
  config_path = Path(path)
102
+ if not config_path.exists():
103
+ _copy_example_config(config_path)
104
+
82
105
  if config_path.exists():
83
106
  try:
84
107
  with open(config_path, encoding="utf-8") as f:
@@ -4,11 +4,18 @@ from datetime import datetime
4
4
  from pathlib import Path
5
5
 
6
6
 
7
+ def _ensure_logs_dir(logs_dir: str = "./data/logs") -> Path:
8
+ """Create and return the logs directory."""
9
+ d = Path(logs_dir)
10
+ d.mkdir(parents=True, exist_ok=True)
11
+ return d
12
+
13
+
7
14
  class KeyLogger:
8
15
  """Structured logger with date-based file rotation."""
9
16
 
10
17
  def __init__(self, logs_dir: str, category: str):
11
- self.logs_dir = Path(logs_dir)
18
+ self.logs_dir = _ensure_logs_dir(logs_dir)
12
19
  self.category = category
13
20
  self.logger = logging.getLogger(f"keymanager.{category}")
14
21
  self.logger.setLevel(logging.DEBUG)
@@ -16,7 +23,6 @@ class KeyLogger:
16
23
  self._setup_file_handler()
17
24
 
18
25
  def _setup_file_handler(self):
19
- self.logs_dir.mkdir(parents=True, exist_ok=True)
20
26
  date_str = datetime.now().strftime("%Y-%m-%d")
21
27
  log_file = self.logs_dir / f"{self.category}_{date_str}.log"
22
28
 
@@ -44,8 +50,7 @@ class ProjectLogger:
44
50
  """Main project logger that logs all operations."""
45
51
 
46
52
  def __init__(self, logs_dir: str = "./data/logs"):
47
- self.logs_dir = Path(logs_dir)
48
- self.logs_dir.mkdir(parents=True, exist_ok=True)
53
+ self.logs_dir = _ensure_logs_dir(logs_dir)
49
54
  self._setup_loggers()
50
55
 
51
56
  def _setup_loggers(self):
@@ -228,5 +233,12 @@ class ProjectLogger:
228
233
  return {"success": True, "date": date, "deleted_lines": lines_count}
229
234
 
230
235
 
231
- # Global logger instance
232
- project_logger = ProjectLogger()
236
+ # Lazy singleton — defers directory creation to first get_project_logger() call
237
+ _project_logger: "ProjectLogger | None" = None
238
+
239
+ def get_project_logger() -> "ProjectLogger":
240
+ """Return the singleton ProjectLogger, creating it on first access."""
241
+ global _project_logger
242
+ if _project_logger is None:
243
+ _project_logger = ProjectLogger()
244
+ return _project_logger
@@ -3,6 +3,7 @@ import json
3
3
  import logging
4
4
  import os
5
5
  import secrets
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  from cryptography.hazmat.primitives import hashes
@@ -14,6 +15,13 @@ from key_manager.errors import ErrorCode, StorageError
14
15
  logger = logging.getLogger(__name__)
15
16
 
16
17
 
18
+ def _resolve_config_path() -> Path:
19
+ """Resolve config.yaml path — next to exe when frozen, CWD otherwise."""
20
+ if getattr(sys, "frozen", False):
21
+ return Path(sys.argv[0]).parent / "config.yaml"
22
+ return Path("config.yaml")
23
+
24
+
17
25
  _ITERATIONS = 600_000 # OWASP 2023 recommendation for PBKDF2-HMAC-SHA256
18
26
  _LEGACY_SALT = b"key-manager-aes256gcm-salt-v1" # For backward compatibility
19
27
  _API_TOKEN_SALT = b"key-manager-api-auth-token-v1" # Domain-specific salt for API token
@@ -51,6 +59,20 @@ def clear_key_cache() -> None:
51
59
  _key_cache.clear()
52
60
 
53
61
 
62
+ def clear_passphrase_cache() -> None:
63
+ """Clear the module-level passphrase cache.
64
+
65
+ Call this in test teardown to prevent cross-test pollution.
66
+ """
67
+ global _passphrase_cache
68
+ _passphrase_cache = None
69
+
70
+
71
+ def clear_all_caches() -> None:
72
+ """Clear all module-level caches (keys + passphrase)."""
73
+ clear_key_cache()
74
+ clear_passphrase_cache()
75
+
54
76
  def derive_api_token(config: dict | None = None, salt: bytes = None) -> str:
55
77
  """Derive an API authentication token from the encryption passphrase.
56
78
 
@@ -80,27 +102,53 @@ def derive_api_token(config: dict | None = None, salt: bytes = None) -> str:
80
102
  _key_cache[cache_key] = token_bytes
81
103
  return token_bytes.hex()
82
104
 
105
+ _passphrase_cache: str | None = None
106
+
83
107
  def _get_passphrase(config: dict | None = None) -> str:
84
- """Get encryption passphrase from config, env var, or auto-generate."""
108
+ """Get encryption passphrase from cache, env var, config, or auto-generate.
109
+
110
+ Uses a module-level cache so all callers get the same passphrase,
111
+ even when config dicts are loaded at different times.
112
+ """
113
+ global _passphrase_cache
114
+ if _passphrase_cache is not None:
115
+ return _passphrase_cache
116
+
85
117
  # 1. Check environment variable
86
118
  secret = os.environ.get("KEY_MANAGER_SECRET")
87
119
  if secret:
120
+ _passphrase_cache = secret
88
121
  return secret
89
122
 
90
- # 2. Check config file
123
+ # 2. Check in-memory config dict (fast path)
91
124
  if config and config.get("encryption", {}).get("passphrase"):
92
- return config["encryption"]["passphrase"]
125
+ _passphrase_cache = config["encryption"]["passphrase"]
126
+ return _passphrase_cache
93
127
 
94
- # 3. Auto-generate and save
128
+ # 3. Read from config file on disk (catches passphrases saved by another caller)
129
+ try:
130
+ import yaml
131
+ config_path = _resolve_config_path()
132
+ if config_path.exists():
133
+ disk_cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
134
+ passphrase = disk_cfg.get("encryption", {}).get("passphrase")
135
+ if passphrase:
136
+ _passphrase_cache = passphrase
137
+ return passphrase
138
+ except Exception:
139
+ pass
140
+
141
+ # 4. Auto-generate and save
95
142
  passphrase = secrets.token_urlsafe(32)
96
143
  _save_passphrase_to_config(passphrase)
144
+ _passphrase_cache = passphrase
97
145
  logger.info("Auto-generated encryption passphrase and saved to config.yaml")
98
146
  return passphrase
99
147
 
100
148
 
101
149
  def _save_passphrase_to_config(passphrase: str) -> None:
102
150
  """Append encryption passphrase to config.yaml without rewriting existing content."""
103
- config_path = Path("config.yaml")
151
+ config_path = _resolve_config_path()
104
152
 
105
153
  try:
106
154
  existing = ""
@@ -178,7 +226,7 @@ class KeyStore:
178
226
  encrypted = self.config.get("storage", {}).get("encrypted", True) is True
179
227
  if not encrypted:
180
228
  logger.warning("rotate_key called but storage.encrypted=false — passphrase not used")
181
- clear_key_cache() # Invalidate stale cached keys from old passphrase
229
+ clear_key_cache()
182
230
  if not self.path.exists():
183
231
  raise StorageError(code=ErrorCode.STORAGE_READ_ERROR, message=f"File not found: {self.path}")
184
232
  raw = self.path.read_text(encoding="utf-8")
@@ -190,9 +238,11 @@ class KeyStore:
190
238
  data = self._decrypt(envelope)
191
239
  else:
192
240
  data = envelope
241
+ clear_passphrase_cache() # clear old cache so _encrypt uses new passphrase
193
242
  self.config.setdefault("encryption", {})["passphrase"] = new_passphrase
194
243
  os.environ["KEY_MANAGER_SECRET"] = new_passphrase
195
244
  self.save(data)
245
+ clear_passphrase_cache() # clean up: don't leak passphrase to caller's env
196
246
  return data
197
247
 
198
248
  def _encrypt(self, data: dict) -> dict:
@@ -14,7 +14,7 @@ from fastapi.templating import Jinja2Templates
14
14
  from key_manager.config import load_config
15
15
  from key_manager.detector import detect_provider # noqa: F401
16
16
  from key_manager.errors import StorageError
17
- from key_manager.logger import project_logger
17
+ from key_manager.logger import get_project_logger
18
18
  from key_manager.parser import (
19
19
  import_keys, # noqa: F401
20
20
  validate_import_path, # noqa: F401
@@ -58,7 +58,7 @@ app = FastAPI(
58
58
  description="Manage and validate API keys for 45+ AI providers. "
59
59
  "Import, check validity, test token limits and concurrency, "
60
60
  "query balances, and export working keys.",
61
- version="5.0.1",
61
+ version="5.0.2",
62
62
  openapi_tags=[
63
63
  {"name": "Keys", "description": "Key management operations"},
64
64
  {"name": "Check", "description": "Key validity checking"},
@@ -76,7 +76,7 @@ app = FastAPI(
76
76
 
77
77
  app.add_middleware(
78
78
  CORSMiddleware,
79
- allow_origins=["http://localhost:18001", "http://127.0.0.1:18001", "http://localhost:3000", "http://127.0.0.1:3000"],
79
+ allow_origins=["http://localhost:18001", "http://127.0.0.1:18001", "http://localhost:3000", "http://127.0.0.1:3000", "null"],
80
80
  allow_credentials=False, # Must be False when allow_origins is "*" (CORS spec)
81
81
  allow_methods=["*"],
82
82
  allow_headers=["*"],
@@ -110,7 +110,7 @@ def _load_keys_data(config_override: dict | None = None) -> dict:
110
110
  except StorageError:
111
111
  raise
112
112
  except Exception as e:
113
- project_logger.main_logger.warning(f"Failed to load/decrypt keys data: {e}")
113
+ get_project_logger().main_logger.warning(f"Failed to load/decrypt keys data: {e}")
114
114
  # Return empty data structure for unexpected errors (e.g., corrupt JSON)
115
115
  return {"keys": {}}
116
116
 
@@ -130,9 +130,20 @@ setup_error_handlers(app)
130
130
  # WEB UI
131
131
  # ---
132
132
 
133
+ _TAURI_DIR = Path(__file__).resolve().parent.parent.parent / "static_tauri"
133
134
  _TEMPLATES_DIR = Path(__file__).resolve().parent.parent.parent / "templates"
134
135
  _TEMPLATES_DIR_ALT = Path(__file__).resolve().parent.parent / "templates"
135
136
 
137
+ # Tauri React SPA: mount assets at /assets
138
+ if (_TAURI_DIR / "assets").is_dir():
139
+ app.mount("/assets", StaticFiles(directory=str(_TAURI_DIR / "assets")), name="tauri_assets")
140
+
141
+ # Legacy static files
142
+ _STATIC_DIR = Path(__file__).resolve().parent.parent.parent / "static"
143
+ if _STATIC_DIR.is_dir():
144
+ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
145
+
146
+ # Jinja2 templates (legacy fallback)
136
147
  if _TEMPLATES_DIR.exists():
137
148
  templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
138
149
  elif _TEMPLATES_DIR_ALT.exists():
@@ -140,16 +151,10 @@ elif _TEMPLATES_DIR_ALT.exists():
140
151
  else:
141
152
  templates = None
142
153
 
143
- # Static files (CSS, JS extracted from inline)
144
- _STATIC_DIR = Path(__file__).resolve().parent.parent.parent / "static"
145
- if _STATIC_DIR.is_dir():
146
- app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
147
-
148
154
 
149
155
  @app.get("/", include_in_schema=False)
150
156
  async def web_ui(request: Request):
151
157
  """Serve the web UI with API token injected."""
152
- # Derive API token for frontend injection
153
158
  api_token = ""
154
159
  try:
155
160
  from key_manager.storage import derive_api_token
@@ -157,13 +162,21 @@ async def web_ui(request: Request):
157
162
  except Exception:
158
163
  pass
159
164
 
165
+ # Tauri React SPA (preferred)
166
+ tauri_index = _TAURI_DIR / "index.html"
167
+ if tauri_index.exists():
168
+ html = tauri_index.read_text(encoding="utf-8")
169
+ html = html.replace("<title>tauri-compare</title>", "<title>KeyHub</title>")
170
+ html = html.replace("</head>", f"<script>window.__API_TOKEN__ = '{api_token}';</script></head>")
171
+ return HTMLResponse(html)
172
+
173
+ # Legacy Jinja2 template fallback
160
174
  if templates and (_TEMPLATES_DIR / "index.html").exists():
161
175
  return templates.TemplateResponse("index.html", {"request": request, "api_token": api_token})
162
176
  if templates and (_TEMPLATES_DIR_ALT / "index.html").exists():
163
177
  return templates.TemplateResponse("index.html", {"request": request, "api_token": api_token})
164
178
  return HTMLResponse("<html><body><h1>API Key Manager</h1><p>Web UI not found.</p></body></html>")
165
179
 
166
-
167
180
  # ---
168
181
  # Route modules (extracted to key_manager/web/routes/)
169
182
  # ---
@@ -94,7 +94,7 @@ async def rate_limit_middleware(request: Request, call_next):
94
94
  # Authentication
95
95
  # ---------------------------------------------------------------------------
96
96
 
97
- _AUTH_WHITELIST = {"/", "/docs", "/redoc", "/openapi.json", "/static/"}
97
+ _AUTH_WHITELIST = {"/", "/docs", "/redoc", "/openapi.json", "/static/", "/assets/"}
98
98
 
99
99
 
100
100
  async def auth_middleware(request: Request, call_next):
@@ -114,19 +114,11 @@ async def auth_middleware(request: Request, call_next):
114
114
  pass
115
115
 
116
116
  if not api_key:
117
- # Log warning once at startup (not on every request)
118
- app = request.app
119
- if not hasattr(app, '_auth_warning_logged'):
120
- logger.warning(
121
- "Authentication is disabled. Set KEY_MANAGER_API_KEY env var or "
122
- "auth.api_key in config.yaml to secure your API."
123
- )
124
- app._auth_warning_logged = True
125
117
  return await call_next(request)
126
118
 
127
119
  # Check whitelist (including static files)
128
120
  path = request.url.path
129
- if path in _AUTH_WHITELIST or path.startswith("/static/"):
121
+ if path in _AUTH_WHITELIST or path.startswith("/static/") or path.startswith("/assets/"):
130
122
  return await call_next(request)
131
123
 
132
124
  auth_header = request.headers.get("authorization", "")
@@ -139,7 +131,6 @@ async def auth_middleware(request: Request, call_next):
139
131
  )
140
132
  return JSONResponse(status_code=401, content=response.model_dump())
141
133
 
142
-
143
134
  # ---------------------------------------------------------------------------
144
135
  # Internationalization
145
136
  # ---------------------------------------------------------------------------
@@ -209,6 +200,20 @@ def setup_middleware(app: FastAPI, config: dict) -> None:
209
200
 
210
201
  def setup_error_handlers(app: FastAPI) -> None:
211
202
  """Register all error handlers on the given FastAPI app."""
203
+ import traceback
204
+
205
+ @app.exception_handler(Exception)
206
+ async def unhandled_exception_handler(request: Request, exc: Exception):
207
+ logger.error(f"Unhandled error on {request.method} {request.url.path}: {exc}")
208
+ logger.error(traceback.format_exc())
209
+ return JSONResponse(
210
+ status_code=500,
211
+ content=ErrorResponse(
212
+ error=ErrorCode.SYSTEM_INTERNAL_ERROR,
213
+ message=f"{type(exc).__name__}: {exc}",
214
+ ).model_dump(),
215
+ )
216
+
212
217
  app.exception_handler(RequestValidationError)(pydantic_validation_error_handler)
213
218
  app.exception_handler(KeyManagerError)(key_manager_error_handler)
214
219
  app.exception_handler(ValidationError)(validation_error_handler)
@@ -8,7 +8,7 @@ import key_manager.web._app as _app_mod
8
8
  from key_manager.api_models import BalanceRequest, BalanceResponse
9
9
  from key_manager.errors import ErrorCode, ValidationError
10
10
  from key_manager.i18n import t
11
- from key_manager.logger import project_logger
11
+ from key_manager.logger import get_project_logger
12
12
  from key_manager.parser import mask_key
13
13
  from key_manager.proxy import get_proxy
14
14
  from key_manager.ssrf import get_allowed_domains, validate_custom_base_url
@@ -79,7 +79,7 @@ async def api_balance(body: BalanceRequest):
79
79
  else:
80
80
  error = "Provider does not support balance queries"
81
81
 
82
- project_logger.log_web_action("balance", f"{mask_key(key)} {provider_name}: {balance_value}")
82
+ get_project_logger().log_web_action("balance", f"{mask_key(key)} {provider_name}: {balance_value}")
83
83
 
84
84
  return BalanceResponse(
85
85
  provider=provider_name,
@@ -19,7 +19,7 @@ from key_manager.api_models import (
19
19
  from key_manager.detector import detect_by_prefix
20
20
  from key_manager.errors import ErrorCode, ValidationError
21
21
  from key_manager.i18n import t
22
- from key_manager.logger import project_logger
22
+ from key_manager.logger import get_project_logger
23
23
  from key_manager.parser import mask_key
24
24
  from key_manager.providers import get_display_name
25
25
  from key_manager.providers.base import CheckResult, simplify_error
@@ -104,7 +104,7 @@ async def api_check(
104
104
  status_filter=s or None,
105
105
  progress_callback=_make_progress_callback(),
106
106
  )
107
- project_logger.log_web_action("check_all", f"total={results.get('total', 0)}")
107
+ get_project_logger().log_web_action("check_all", f"total={results.get('total', 0)}")
108
108
 
109
109
  # Dispatch webhook
110
110
  task = asyncio.create_task(
@@ -215,7 +215,7 @@ async def api_check_single(body: CheckSingleRequest):
215
215
 
216
216
  status_str = "valid" if result.valid else ("invalid" if result.status_code in (401, 403) else "error")
217
217
 
218
- project_logger.log_web_action("check_single", f"{mask_key(key)} {provider_name}: {status_str}")
218
+ get_project_logger().log_web_action("check_single", f"{mask_key(key)} {provider_name}: {status_str}")
219
219
 
220
220
  # Simplify error message for readability
221
221
  simplified_error = simplify_error(result.error, result.status_code) if result.error else None
@@ -242,7 +242,7 @@ async def api_check_single(body: CheckSingleRequest):
242
242
  "created_at": timestamp,
243
243
  }
244
244
  _app_mod._save_keys_data(data)
245
- project_logger.log_web_action("auto_import", f"{mask_key(key)} from check")
245
+ get_project_logger().log_web_action("auto_import", f"{mask_key(key)} from check")
246
246
  else:
247
247
  # Update existing key status
248
248
  keys_dict[key]["status"] = status_str
@@ -253,7 +253,7 @@ async def api_check_single(body: CheckSingleRequest):
253
253
  keys_dict[key]["checks"].append({"status": status_str, "provider": provider_name, "timestamp": timestamp})
254
254
  _app_mod._save_keys_data(data)
255
255
  except Exception as e:
256
- project_logger.main_logger.warning(f"Failed to auto-save checked key: {e}")
256
+ get_project_logger().main_logger.warning(f"Failed to auto-save checked key: {e}")
257
257
 
258
258
  return CheckSingleResponse(
259
259
  key_masked=mask_key(key),
@@ -352,6 +352,6 @@ async def api_check_batch(body: CheckBatchRequest):
352
352
  summary.error += 1
353
353
 
354
354
  summary.total = len(batch_results)
355
- project_logger.log_web_action("check_batch", f"total={summary.total}, valid={summary.valid}")
355
+ get_project_logger().log_web_action("check_batch", f"total={summary.total}, valid={summary.valid}")
356
356
 
357
357
  return CheckBatchResponse(results=results, summary=summary)
@@ -19,7 +19,7 @@ from key_manager.api_models import (
19
19
  )
20
20
  from key_manager.errors import ErrorCode, ErrorResponse, ValidationError
21
21
  from key_manager.i18n import t
22
- from key_manager.logger import project_logger
22
+ from key_manager.logger import get_project_logger
23
23
  from key_manager.parser import mask_key
24
24
 
25
25
  router = APIRouter(tags=["Keys"])
@@ -62,7 +62,7 @@ async def api_import(body: ImportRequest):
62
62
  }
63
63
  new += 1
64
64
  _app_mod._save_keys_data(data)
65
- project_logger.log_web_action("import", f"batch: {new} new, {dupes} dupes")
65
+ get_project_logger().log_web_action("import", f"batch: {new} new, {dupes} dupes")
66
66
  return ImportResponse(new=new, duplicates=dupes, errors=errors)
67
67
 
68
68
  allowed_dirs = _app_mod.config.get("scan", {}).get("directories", ["./data/input"])
@@ -80,7 +80,7 @@ async def api_import(body: ImportRequest):
80
80
  batch=None,
81
81
  keys_file=_app_mod.config["storage"]["keys_file"],
82
82
  )
83
- project_logger.log_web_action("import", f"{body.file or body.directory}: {new} new, {dupes} dupes")
83
+ get_project_logger().log_web_action("import", f"{body.file or body.directory}: {new} new, {dupes} dupes")
84
84
  return ImportResponse(new=new, duplicates=dupes, errors=errors)
85
85
 
86
86
 
@@ -143,7 +143,7 @@ async def api_import_upload(request: Request):
143
143
  except Exception:
144
144
  pass
145
145
 
146
- project_logger.log_web_action("import_upload", f"{filename}: {new} new, {dupes} dupes")
146
+ get_project_logger().log_web_action("import_upload", f"{filename}: {new} new, {dupes} dupes")
147
147
  return ImportResponse(new=new, duplicates=dupes, errors=errors)
148
148
 
149
149
 
@@ -225,7 +225,7 @@ async def api_export_keys(
225
225
  max_concurrency=tests.get("max_concurrency"),
226
226
  ))
227
227
 
228
- project_logger.log_web_action("export", f"{len(exported)} keys")
228
+ get_project_logger().log_web_action("export", f"{len(exported)} keys")
229
229
  return KeyExportResponse(keys=exported, total=len(exported))
230
230
 
231
231
 
@@ -240,7 +240,7 @@ async def api_clear_keys():
240
240
  cleared = len(data.get("keys", {}))
241
241
  data["keys"] = {}
242
242
  _app_mod._save_keys_data(data)
243
- project_logger.log_web_action("clear", f"{cleared} keys removed")
243
+ get_project_logger().log_web_action("clear", f"{cleared} keys removed")
244
244
  return {"cleared": cleared}
245
245
 
246
246
 
@@ -268,7 +268,7 @@ async def api_get_full_key(request: Request):
268
268
  # Find the full key by matching key_masked
269
269
  for full_key, info in keys_dict.items():
270
270
  if info.get("key_masked") == key_masked:
271
- project_logger.log_web_action("get_full_key", key_masked)
271
+ get_project_logger().log_web_action("get_full_key", key_masked)
272
272
  return {"key": full_key}
273
273
 
274
274
  raise ValidationError(
@@ -308,7 +308,7 @@ async def api_delete_key(request: Request):
308
308
  if key_to_delete:
309
309
  del keys_dict[key_to_delete]
310
310
  _app_mod._save_keys_data(data)
311
- project_logger.log_web_action("delete", key_masked)
311
+ get_project_logger().log_web_action("delete", key_masked)
312
312
  return {"deleted": 1, "key_masked": key_masked}
313
313
 
314
314
  raise ValidationError(
@@ -15,7 +15,7 @@ from key_manager.api_models import (
15
15
  ProxyResponse,
16
16
  )
17
17
  from key_manager.errors import ErrorCode, ValidationError
18
- from key_manager.logger import project_logger
18
+ from key_manager.logger import get_project_logger
19
19
  from key_manager.proxy import get_proxy
20
20
  from key_manager.web.progress import _progress_tracker, _sse_progress_event_generator
21
21
  from key_manager.webhook import webhook_manager
@@ -53,14 +53,14 @@ async def api_proxy():
53
53
  @router.get("/api/logs", tags=["Logs"], response_model=LogsResponse)
54
54
  async def api_logs():
55
55
  """Get recent log entries."""
56
- logs = project_logger.get_recent_logs()
56
+ logs = get_project_logger().get_recent_logs()
57
57
  return LogsResponse(logs=logs) # Return strings directly for frontend compatibility
58
58
 
59
59
 
60
60
  @router.get("/api/logs/operations", tags=["Logs"], response_model=OperationsResponse)
61
61
  async def api_logs_operations():
62
62
  """Get recent operation log entries."""
63
- operations = project_logger.get_operations_log()
63
+ operations = get_project_logger().get_operations_log()
64
64
  return OperationsResponse(operations=[OperationEntry(**entry) for entry in operations])
65
65
 
66
66
 
@@ -74,7 +74,7 @@ async def api_logs_clear(date: str = None):
74
74
  Returns:
75
75
  dict with success status and details
76
76
  """
77
- result = project_logger.clear_main_log(date)
77
+ result = get_project_logger().clear_main_log(date)
78
78
  return result
79
79
 
80
80
 
@@ -10,7 +10,7 @@ import key_manager.web._app as _app_mod
10
10
  from key_manager.api_models import TestSingleRequest, TestSingleResponse
11
11
  from key_manager.errors import ErrorCode, ValidationError
12
12
  from key_manager.i18n import t
13
- from key_manager.logger import project_logger
13
+ from key_manager.logger import get_project_logger
14
14
  from key_manager.parser import mask_key
15
15
  from key_manager.proxy import get_proxy
16
16
  from key_manager.tester import run_test
@@ -40,7 +40,7 @@ async def api_test():
40
40
  progress_callback=_make_progress_callback(),
41
41
  )
42
42
  _progress_tracker.done("done", results)
43
- project_logger.log_web_action("test_all", f"tested={results.get('total_tested', 0)}")
43
+ get_project_logger().log_web_action("test_all", f"tested={results.get('total_tested', 0)}")
44
44
  await webhook_manager.dispatch(
45
45
  WebhookEvent.BATCH_TEST_COMPLETED,
46
46
  {"total_tested": results.get("total_tested", 0)},
@@ -128,7 +128,7 @@ async def api_test_single(body: TestSingleRequest):
128
128
  except Exception:
129
129
  pass
130
130
 
131
- project_logger.log_web_action(
131
+ get_project_logger().log_web_action(
132
132
  "test_single",
133
133
  f"{mask_key(key)} {provider_name}: tokens={max_tokens}, concurrency={max_concurrency}",
134
134
  )
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "api-key-manager"
7
- version = "5.0.1"
7
+ version = "5.0.2"
8
8
  description = "Batch manage API keys for 45+ AI providers with CLI and Web interfaces"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}