api-key-manager 2.1.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.
- api_key_manager-2.1.0.dist-info/METADATA +709 -0
- api_key_manager-2.1.0.dist-info/RECORD +73 -0
- api_key_manager-2.1.0.dist-info/WHEEL +5 -0
- api_key_manager-2.1.0.dist-info/entry_points.txt +2 -0
- api_key_manager-2.1.0.dist-info/top_level.txt +1 -0
- key_manager/__init__.py +16 -0
- key_manager/__main__.py +5 -0
- key_manager/api_models.py +358 -0
- key_manager/checker.py +51 -0
- key_manager/cli.py +270 -0
- key_manager/config.py +61 -0
- key_manager/core.py +205 -0
- key_manager/detector.py +335 -0
- key_manager/errors.py +179 -0
- key_manager/i18n.py +142 -0
- key_manager/logger.py +207 -0
- key_manager/model_capabilities.py +412 -0
- key_manager/parser.py +153 -0
- key_manager/providers/__init__.py +283 -0
- key_manager/providers/ai302.py +109 -0
- key_manager/providers/anthropic.py +109 -0
- key_manager/providers/baichuan.py +97 -0
- key_manager/providers/base.py +312 -0
- key_manager/providers/cerebras.py +109 -0
- key_manager/providers/cohere.py +90 -0
- key_manager/providers/cstcloud.py +122 -0
- key_manager/providers/dashscope.py +120 -0
- key_manager/providers/dashscope_coding.py +122 -0
- key_manager/providers/deepseek.py +166 -0
- key_manager/providers/dmxapi.py +109 -0
- key_manager/providers/doubao.py +109 -0
- key_manager/providers/fireworks.py +109 -0
- key_manager/providers/google.py +99 -0
- key_manager/providers/grok.py +109 -0
- key_manager/providers/groq.py +109 -0
- key_manager/providers/huggingface.py +54 -0
- key_manager/providers/hyperbolic.py +109 -0
- key_manager/providers/infini.py +135 -0
- key_manager/providers/infini_coding.py +124 -0
- key_manager/providers/kimi.py +121 -0
- key_manager/providers/kimi_coding.py +124 -0
- key_manager/providers/longcat.py +123 -0
- key_manager/providers/mimo.py +109 -0
- key_manager/providers/mimo_plan.py +140 -0
- key_manager/providers/minimax.py +97 -0
- key_manager/providers/minimax_plan.py +122 -0
- key_manager/providers/mistral.py +109 -0
- key_manager/providers/models_registry.py +2901 -0
- key_manager/providers/modelscope.py +134 -0
- key_manager/providers/nvidia.py +109 -0
- key_manager/providers/ocoolai.py +109 -0
- key_manager/providers/openai.py +140 -0
- key_manager/providers/openrouter.py +119 -0
- key_manager/providers/perplexity.py +109 -0
- key_manager/providers/poe.py +109 -0
- key_manager/providers/ppio.py +109 -0
- key_manager/providers/replicate.py +54 -0
- key_manager/providers/siliconflow.py +121 -0
- key_manager/providers/stepfun.py +132 -0
- key_manager/providers/tencent_hunyuan.py +122 -0
- key_manager/providers/together.py +134 -0
- key_manager/providers/yi.py +97 -0
- key_manager/providers/zai.py +109 -0
- key_manager/providers/zhipu.py +127 -0
- key_manager/providers/zhipu_coding.py +124 -0
- key_manager/proxy.py +70 -0
- key_manager/ssrf.py +68 -0
- key_manager/storage.py +134 -0
- key_manager/tester.py +137 -0
- key_manager/url_override.py +5 -0
- key_manager/validator.py +185 -0
- key_manager/web.py +1512 -0
- key_manager/webhook.py +257 -0
key_manager/detector.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import re
|
|
3
|
+
from .providers import PROVIDERS, KEY_PREFIX_MAP, PROVIDER_ERROR_SIGNATURES
|
|
4
|
+
from .providers.models_registry import PROVIDER_MODELS
|
|
5
|
+
|
|
6
|
+
# Extended key patterns for better detection
|
|
7
|
+
KEY_PATTERNS = {
|
|
8
|
+
# AI Providers - unique prefixes for pattern detection
|
|
9
|
+
"sk-or-v1-": "openrouter",
|
|
10
|
+
"sk-ant-api03-": "anthropic",
|
|
11
|
+
"sk-proj-": "openai",
|
|
12
|
+
"sk-sp-": "dashscope",
|
|
13
|
+
"sk-kimi-": "kimi-coding",
|
|
14
|
+
"sk-cp-": "minimax-plan",
|
|
15
|
+
"ms-": "modelscope",
|
|
16
|
+
"AIza": "google",
|
|
17
|
+
"xai-": "grok",
|
|
18
|
+
"hf_": "huggingface",
|
|
19
|
+
"r8_": "replicate",
|
|
20
|
+
"pplx-": "perplexity",
|
|
21
|
+
"gsk_": "groq",
|
|
22
|
+
"fw_": "fireworks",
|
|
23
|
+
"poe-": "poe",
|
|
24
|
+
"AKID": "cstcloud",
|
|
25
|
+
"tp-": "mimo-plan",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Scoring weights
|
|
30
|
+
WEIGHT_SELF = 100 # Self-signature match = definitive
|
|
31
|
+
WEIGHT_CROSS = 10 # Cross-signature match = ambiguous
|
|
32
|
+
WEIGHT_RATE_LIMITED = 60 # 429 rate limited = medium confidence (lower than 200/401/403 but still usable)
|
|
33
|
+
MIN_WIN_SCORE = 50 # Minimum score to declare a winner
|
|
34
|
+
MIN_LEAD = 20 # Minimum lead over second place
|
|
35
|
+
|
|
36
|
+
# Unique signatures that ONLY belong to one provider.
|
|
37
|
+
# These are verified by actual API testing.
|
|
38
|
+
UNIQUE_SIGNATURES: dict[str, list[str]] = {
|
|
39
|
+
# ═══ 国内服务商 ═══
|
|
40
|
+
# dashscope: 实际返回 "Incorrect API key provided. For details, see: https://help.aliyun.com/zh/model-studio/error-code#apikey-error"
|
|
41
|
+
"dashscope": ["model-studio", "modelstudio", "apikey-error"],
|
|
42
|
+
"dashscope-coding": ["aliyun", "model-studio", "modelstudio"],
|
|
43
|
+
# tencent-hunyuan: 实际返回 "Incorrect API key provided: sk-inval...You can find your API key at https://console.cloud.tencent.com/hunyuan/start"
|
|
44
|
+
"tencent-hunyuan": ["hunyuan", "console.cloud.tencent.com"],
|
|
45
|
+
"baichuan": ["baichuan-ai.com", "platform.baichuan-ai.com"],
|
|
46
|
+
# minimax: 实际返回 "authorized_error", "login fail"
|
|
47
|
+
"minimax": ["authorized_error", "login fail"],
|
|
48
|
+
"minimax-plan": ["authorized_error", "login fail"],
|
|
49
|
+
# yi: 实际返回 "Illegal ApiKey"
|
|
50
|
+
"yi": ["illegal apikey"],
|
|
51
|
+
# kimi: 实际返回 "invalid_authentication_error"
|
|
52
|
+
"kimi": ["invalid_authentication_error"],
|
|
53
|
+
"kimi-coding": ["invalid_authentication_error", "the api key appears to be invalid"],
|
|
54
|
+
# siliconflow: 实际返回 "Api key is invalid" (注意大小写)
|
|
55
|
+
"siliconflow": ["api key is invalid"],
|
|
56
|
+
# stepfun: 实际返回 "Incorrect API key provided" (与 dashscope 重复,用 type 区分)
|
|
57
|
+
"stepfun": ["incorrect api key provided", "invalid_api_key"],
|
|
58
|
+
# doubao: 实际返回 "AuthenticationError"
|
|
59
|
+
"doubao": ["authenticationerror"],
|
|
60
|
+
# infini: 实际返回 "请使用正确的api key进行请求"
|
|
61
|
+
"infini": ["请使用正确的api key进行请求"],
|
|
62
|
+
"infini-coding": ["请使用正确的api key进行请求"],
|
|
63
|
+
# zhipu: 实际返回 "令牌已过期或验证不正确"
|
|
64
|
+
"zhipu": ["令牌已过期或验证不正确"],
|
|
65
|
+
"zhipu-coding": ["令牌已过期或验证不正确"],
|
|
66
|
+
# mimo: 实际返回 "Invalid API Key", "Please provide valid API Key"
|
|
67
|
+
"mimo": ["invalid api key", "please provide valid api key"],
|
|
68
|
+
"mimo-plan": ["invalid api key", "please provide valid api key"],
|
|
69
|
+
# cstcloud: 实际返回 {"code":401,"message":"Unauthorized"}
|
|
70
|
+
"cstcloud": ["cstcloud", "zhongsuanyun"],
|
|
71
|
+
"modelscope": ["modelscope"],
|
|
72
|
+
"longcat": ["longcat"],
|
|
73
|
+
"ppio": ["ppio"],
|
|
74
|
+
# ═══ 国外服务商 ═══
|
|
75
|
+
# deepseek: 实际返回 "Authentication Fails, Your api key: ****2345 is invalid"
|
|
76
|
+
"deepseek": ["authentication fails"],
|
|
77
|
+
# anthropic: 实际返回 "Request not allowed"
|
|
78
|
+
"anthropic": ["request not allowed", "anthropic", "x-api-key"],
|
|
79
|
+
# openrouter: 实际返回 "Missing Authentication header"
|
|
80
|
+
"openrouter": ["missing authentication header"],
|
|
81
|
+
# together: 实际返回 "Unauthorized"
|
|
82
|
+
"together": [],
|
|
83
|
+
"mistral": ["mistral", "la plateforme"],
|
|
84
|
+
# cohere: 实际返回 403 HTML 页面
|
|
85
|
+
"cohere": [],
|
|
86
|
+
# replicate: 实际返回 "Unauthenticated"
|
|
87
|
+
"replicate": ["unauthenticated", "you did not pass a valid authentication token"],
|
|
88
|
+
"huggingface": ["huggingface", "hf_"],
|
|
89
|
+
"fireworks": ["fireworks", "accounts/fireworks"],
|
|
90
|
+
"perplexity": ["perplexity"],
|
|
91
|
+
# grok: 实际返回 "Incorrect API key provided: sk***45...console.x.ai."
|
|
92
|
+
"grok": ["console.x.ai"],
|
|
93
|
+
"cerebras": ["cerebras"],
|
|
94
|
+
"nvidia": ["nvidia", "nim.api"],
|
|
95
|
+
# hyperbolic: 实际返回 "Could not validate credentials"
|
|
96
|
+
"hyperbolic": ["could not validate credentials"],
|
|
97
|
+
"poe": ["poe.com"],
|
|
98
|
+
"ai302": ["302.ai"],
|
|
99
|
+
# dmxapi: 实际返回 "rix_api_error"
|
|
100
|
+
"dmxapi": ["rix_api_error"],
|
|
101
|
+
# ocoolai: 实际返回 "shell_api_error"
|
|
102
|
+
"ocoolai": ["shell_api_error"],
|
|
103
|
+
# zai: 实际返回 "token expired or incorrect"
|
|
104
|
+
"zai": ["token expired or incorrect"],
|
|
105
|
+
# openai: 实际返回 "Incorrect API key provided: sk-inval...platform.openai.com..."
|
|
106
|
+
"openai": ["platform.openai.com"],
|
|
107
|
+
"google": ["generativelanguage"],
|
|
108
|
+
"groq": ["groq"],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Zhipu/Z.AI key format: {id}.{secret} (dot-separated alphanumeric)
|
|
112
|
+
# This format is unique and highly identifiable
|
|
113
|
+
# Part 1 (id): 20-50 chars, Part 2 (secret): 10-50 chars
|
|
114
|
+
ZHIPU_KEY_PATTERN = re.compile(r'^[a-zA-Z0-9]{20,50}\.[a-zA-Z0-9]{10,50}$')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def detect_by_prefix(key: str) -> list[str]:
|
|
118
|
+
"""Return candidates from the LONGEST matching prefix only."""
|
|
119
|
+
for prefix, providers in sorted(KEY_PREFIX_MAP.items(), key=lambda x: -len(x[0])):
|
|
120
|
+
if key.startswith(prefix):
|
|
121
|
+
return list(providers)
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def detect_by_pattern(key: str) -> str:
|
|
126
|
+
"""Detect provider by key pattern."""
|
|
127
|
+
for pattern, provider in sorted(KEY_PATTERNS.items(), key=lambda x: -len(x[0])):
|
|
128
|
+
if key.startswith(pattern):
|
|
129
|
+
return provider
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def detect_by_format(key: str) -> list[str]:
|
|
134
|
+
"""Detect providers by key format (e.g., Zhipu's {id}.{secret} format).
|
|
135
|
+
|
|
136
|
+
Returns a list of candidate providers that use this format.
|
|
137
|
+
The caller will probe all candidates and return the first one that responds 200.
|
|
138
|
+
"""
|
|
139
|
+
# Zhipu/Z.AI key format: {id}.{secret}
|
|
140
|
+
# This format is unique to Zhipu and Z.AI platforms
|
|
141
|
+
if ZHIPU_KEY_PATTERN.match(key):
|
|
142
|
+
return ["zhipu", "zai"] # Both candidates, first 200 wins
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def score_provider(provider_name: str, error_body: str, status_code: int = None) -> int:
|
|
147
|
+
"""
|
|
148
|
+
Score how likely the error body belongs to this provider.
|
|
149
|
+
|
|
150
|
+
Uses UNIQUE_SIGNATURES (verified by actual API testing).
|
|
151
|
+
Each unique signature match adds WEIGHT_SELF points.
|
|
152
|
+
429 (rate limited) gets much lower weight since it doesn't confirm key ownership.
|
|
153
|
+
"""
|
|
154
|
+
body = error_body.lower()
|
|
155
|
+
score = 0
|
|
156
|
+
|
|
157
|
+
# 429 rate limited gets reduced weight - it only means "too many requests"
|
|
158
|
+
# not "this key belongs to this provider"
|
|
159
|
+
weight = WEIGHT_RATE_LIMITED if status_code == 429 else WEIGHT_SELF
|
|
160
|
+
|
|
161
|
+
# Check unique signatures (verified by actual API testing)
|
|
162
|
+
sigs = UNIQUE_SIGNATURES.get(provider_name, [])
|
|
163
|
+
for sig in sigs:
|
|
164
|
+
if sig.lower() in body:
|
|
165
|
+
score += weight
|
|
166
|
+
|
|
167
|
+
return score
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def detect_provider(client, key: str, suspected_provider: str = None) -> str:
|
|
171
|
+
"""Detect provider by concurrently probing ALL providers with multiple models.
|
|
172
|
+
|
|
173
|
+
Strategy:
|
|
174
|
+
1. If suspected_provider given, try it first
|
|
175
|
+
2. If key matches unique pattern, try that provider
|
|
176
|
+
3. Otherwise, concurrently probe ALL providers with their top 5 models
|
|
177
|
+
4. First provider returning 200 wins
|
|
178
|
+
"""
|
|
179
|
+
import time
|
|
180
|
+
|
|
181
|
+
# Step 1: If suspected provider, try it first
|
|
182
|
+
if suspected_provider:
|
|
183
|
+
provider_name = suspected_provider.lower()
|
|
184
|
+
if provider_name in PROVIDERS:
|
|
185
|
+
provider = PROVIDERS[provider_name]
|
|
186
|
+
result = await provider.check(client, key)
|
|
187
|
+
if result.valid:
|
|
188
|
+
return provider_name
|
|
189
|
+
|
|
190
|
+
# Step 2: Try pattern matching for unique prefixes
|
|
191
|
+
pattern_match = detect_by_pattern(key)
|
|
192
|
+
if pattern_match and pattern_match in PROVIDERS:
|
|
193
|
+
provider = PROVIDERS[pattern_match]
|
|
194
|
+
result = await provider.check(client, key)
|
|
195
|
+
if result.valid:
|
|
196
|
+
return pattern_match
|
|
197
|
+
|
|
198
|
+
# Step 3: Try format matching (e.g., Zhipu's {id}.{secret})
|
|
199
|
+
format_candidates = detect_by_format(key)
|
|
200
|
+
if format_candidates:
|
|
201
|
+
# Debug logging
|
|
202
|
+
try:
|
|
203
|
+
from webdebug import debug_logger
|
|
204
|
+
import asyncio as _asyncio
|
|
205
|
+
_asyncio.create_task(debug_logger.log(
|
|
206
|
+
category="DETECT",
|
|
207
|
+
action="detect_by_format",
|
|
208
|
+
detail=f"Key format matched {len(format_candidates)} candidates",
|
|
209
|
+
data={"key_prefix": key[:10] + "...", "candidates": format_candidates},
|
|
210
|
+
level="INFO"
|
|
211
|
+
))
|
|
212
|
+
except ImportError:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
# Try each format candidate
|
|
216
|
+
async def try_format(name):
|
|
217
|
+
if name in PROVIDERS:
|
|
218
|
+
result = await PROVIDERS[name].check(client, key)
|
|
219
|
+
return name, result.valid
|
|
220
|
+
return name, False
|
|
221
|
+
format_tasks = [try_format(n) for n in format_candidates]
|
|
222
|
+
format_results = await asyncio.gather(*format_tasks)
|
|
223
|
+
for name, valid in format_results:
|
|
224
|
+
if valid:
|
|
225
|
+
return name
|
|
226
|
+
# Step 4: Concurrently probe ALL providers with their top 5 models
|
|
227
|
+
# Build tasks: (provider_name, model) pairs
|
|
228
|
+
tasks = []
|
|
229
|
+
for name, provider in PROVIDERS.items():
|
|
230
|
+
models = PROVIDER_MODELS.get(name, [])
|
|
231
|
+
if not models:
|
|
232
|
+
models = [getattr(provider, 'check_model', 'gpt-3.5-turbo')]
|
|
233
|
+
|
|
234
|
+
# Use first 5 models
|
|
235
|
+
for model in models[:5]:
|
|
236
|
+
tasks.append((name, model))
|
|
237
|
+
|
|
238
|
+
# Concurrently check all (provider, model) pairs
|
|
239
|
+
async def try_model(name, model):
|
|
240
|
+
provider = PROVIDERS[name]
|
|
241
|
+
headers = provider.build_headers(key)
|
|
242
|
+
headers["Content-Type"] = "application/json"
|
|
243
|
+
try:
|
|
244
|
+
resp = await asyncio.wait_for(
|
|
245
|
+
client.post(
|
|
246
|
+
f"{provider.get_base_url()}/chat/completions",
|
|
247
|
+
headers=headers,
|
|
248
|
+
json={"model": model, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}
|
|
249
|
+
),
|
|
250
|
+
timeout=10.0
|
|
251
|
+
)
|
|
252
|
+
body = resp.text[:500] if resp.text else ""
|
|
253
|
+
if resp.status_code == 200:
|
|
254
|
+
return name, True, body
|
|
255
|
+
elif resp.status_code in (401, 403):
|
|
256
|
+
# Invalid key, but return body for signature matching
|
|
257
|
+
return name, False, body
|
|
258
|
+
return name, False, body
|
|
259
|
+
except:
|
|
260
|
+
return name, False, ""
|
|
261
|
+
|
|
262
|
+
# Fire all tasks concurrently
|
|
263
|
+
all_tasks = [try_model(name, model) for name, model in tasks]
|
|
264
|
+
|
|
265
|
+
# Collect results for signature matching
|
|
266
|
+
valid_provider = None
|
|
267
|
+
error_bodies = {} # name -> list of error bodies
|
|
268
|
+
|
|
269
|
+
for coro in asyncio.as_completed(all_tasks):
|
|
270
|
+
name, valid, body = await coro
|
|
271
|
+
if valid:
|
|
272
|
+
# Found valid provider, return immediately
|
|
273
|
+
return name
|
|
274
|
+
elif body:
|
|
275
|
+
# Collect error body for signature matching
|
|
276
|
+
if name not in error_bodies:
|
|
277
|
+
error_bodies[name] = []
|
|
278
|
+
error_bodies[name].append(body)
|
|
279
|
+
|
|
280
|
+
# No valid provider found - try signature matching on error bodies
|
|
281
|
+
# Only return if we have a VERY HIGH confidence match (multiple signatures matched)
|
|
282
|
+
best_score = -1
|
|
283
|
+
best_name = None
|
|
284
|
+
|
|
285
|
+
for name, bodies in error_bodies.items():
|
|
286
|
+
for body in bodies:
|
|
287
|
+
score = score_provider(name, body, 401)
|
|
288
|
+
if score > best_score:
|
|
289
|
+
best_score = score
|
|
290
|
+
best_name = name
|
|
291
|
+
|
|
292
|
+
# Debug logging for signature matching
|
|
293
|
+
try:
|
|
294
|
+
from webdebug import debug_logger
|
|
295
|
+
import asyncio as _asyncio
|
|
296
|
+
_asyncio.create_task(debug_logger.log(
|
|
297
|
+
category="DETECT",
|
|
298
|
+
action="signature_matching",
|
|
299
|
+
detail=f"best_score={best_score}, best_name={best_name}",
|
|
300
|
+
data={"best_score": best_score, "best_name": best_name, "error_bodies_count": len(error_bodies)},
|
|
301
|
+
level="INFO"
|
|
302
|
+
))
|
|
303
|
+
except ImportError:
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
# Only return if we have a VERY HIGH confidence match
|
|
307
|
+
# Require at least 2 signature matches (200 points) to avoid false positives
|
|
308
|
+
if best_score >= 200: # At least 2 signatures matched
|
|
309
|
+
return best_name
|
|
310
|
+
|
|
311
|
+
# No provider found with high confidence
|
|
312
|
+
# This is better than returning a wrong provider
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def _try_provider(client, provider, key: str) -> dict:
|
|
317
|
+
"""Probe a provider and return the result with error body for scoring."""
|
|
318
|
+
try:
|
|
319
|
+
# Use a short timeout for detection
|
|
320
|
+
result = await asyncio.wait_for(provider.probe(client, key), timeout=8.0)
|
|
321
|
+
error_body = result.response_body or ""
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
'valid': result.valid,
|
|
325
|
+
'status_code': result.status_code,
|
|
326
|
+
'error_body': error_body
|
|
327
|
+
}
|
|
328
|
+
except Exception as e:
|
|
329
|
+
import logging
|
|
330
|
+
logging.getLogger(__name__).debug(f"Probe failed for {provider.name}: {e}")
|
|
331
|
+
return {'valid': False, 'status_code': None, 'error_body': ''}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def _try_unknown_provider() -> dict:
|
|
335
|
+
return {'valid': False, 'status_code': None, 'error_body': ''}
|
key_manager/errors.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ErrorCode(str, Enum):
|
|
8
|
+
"""Structured error codes for the API Key Manager."""
|
|
9
|
+
|
|
10
|
+
# Validation errors (1xxx)
|
|
11
|
+
VALIDATION_MISSING_KEY = "VALIDATION_MISSING_KEY"
|
|
12
|
+
VALIDATION_INVALID_FORMAT = "VALIDATION_INVALID_FORMAT"
|
|
13
|
+
VALIDATION_PROVIDER_UNKNOWN = "VALIDATION_PROVIDER_UNKNOWN"
|
|
14
|
+
VALIDATION_FILE_NOT_FOUND = "VALIDATION_FILE_NOT_FOUND"
|
|
15
|
+
VALIDATION_FILE_FORMAT = "VALIDATION_FILE_FORMAT"
|
|
16
|
+
|
|
17
|
+
# Storage errors (2xxx)
|
|
18
|
+
STORAGE_READ_ERROR = "STORAGE_READ_ERROR"
|
|
19
|
+
STORAGE_WRITE_ERROR = "STORAGE_WRITE_ERROR"
|
|
20
|
+
STORAGE_ENCRYPTION_ERROR = "STORAGE_ENCRYPTION_ERROR"
|
|
21
|
+
STORAGE_MIGRATION_ERROR = "STORAGE_MIGRATION_ERROR"
|
|
22
|
+
|
|
23
|
+
# Provider errors (3xxx)
|
|
24
|
+
PROVIDER_CHECK_FAILED = "PROVIDER_CHECK_FAILED"
|
|
25
|
+
PROVIDER_NOT_SUPPORTED = "PROVIDER_NOT_SUPPORTED"
|
|
26
|
+
PROVIDER_RATE_LIMITED = "PROVIDER_RATE_LIMITED"
|
|
27
|
+
|
|
28
|
+
# System errors (4xxx)
|
|
29
|
+
SYSTEM_INTERNAL_ERROR = "SYSTEM_INTERNAL_ERROR"
|
|
30
|
+
SYSTEM_PROGRESS_CONFLICT = "SYSTEM_PROGRESS_CONFLICT"
|
|
31
|
+
|
|
32
|
+
# Auth errors (5xxx)
|
|
33
|
+
AUTH_REQUIRED = "AUTH_REQUIRED"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Default HTTP status codes for each error code
|
|
37
|
+
ERROR_STATUS_CODES: dict[ErrorCode, int] = {
|
|
38
|
+
ErrorCode.VALIDATION_MISSING_KEY: 400,
|
|
39
|
+
ErrorCode.VALIDATION_INVALID_FORMAT: 400,
|
|
40
|
+
ErrorCode.VALIDATION_PROVIDER_UNKNOWN: 400,
|
|
41
|
+
ErrorCode.VALIDATION_FILE_NOT_FOUND: 404,
|
|
42
|
+
ErrorCode.VALIDATION_FILE_FORMAT: 400,
|
|
43
|
+
ErrorCode.STORAGE_READ_ERROR: 500,
|
|
44
|
+
ErrorCode.STORAGE_WRITE_ERROR: 500,
|
|
45
|
+
ErrorCode.STORAGE_ENCRYPTION_ERROR: 500,
|
|
46
|
+
ErrorCode.STORAGE_MIGRATION_ERROR: 500,
|
|
47
|
+
ErrorCode.PROVIDER_CHECK_FAILED: 502,
|
|
48
|
+
ErrorCode.PROVIDER_NOT_SUPPORTED: 400,
|
|
49
|
+
ErrorCode.PROVIDER_RATE_LIMITED: 429,
|
|
50
|
+
ErrorCode.SYSTEM_INTERNAL_ERROR: 500,
|
|
51
|
+
ErrorCode.SYSTEM_PROGRESS_CONFLICT: 409,
|
|
52
|
+
ErrorCode.AUTH_REQUIRED: 401,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Default human-readable messages for each error code
|
|
56
|
+
DEFAULT_MESSAGES: dict[ErrorCode, str] = {
|
|
57
|
+
ErrorCode.VALIDATION_MISSING_KEY: "API key is required",
|
|
58
|
+
ErrorCode.VALIDATION_INVALID_FORMAT: "API key format is invalid",
|
|
59
|
+
ErrorCode.VALIDATION_PROVIDER_UNKNOWN: "Unable to detect provider, please select manually",
|
|
60
|
+
ErrorCode.VALIDATION_FILE_NOT_FOUND: "File not found",
|
|
61
|
+
ErrorCode.VALIDATION_FILE_FORMAT: "Unsupported file format",
|
|
62
|
+
ErrorCode.STORAGE_READ_ERROR: "Failed to read from storage",
|
|
63
|
+
ErrorCode.STORAGE_WRITE_ERROR: "Failed to write to storage",
|
|
64
|
+
ErrorCode.STORAGE_ENCRYPTION_ERROR: "Encryption/decryption operation failed",
|
|
65
|
+
ErrorCode.STORAGE_MIGRATION_ERROR: "Data migration failed",
|
|
66
|
+
ErrorCode.PROVIDER_CHECK_FAILED: "Provider key check failed",
|
|
67
|
+
ErrorCode.PROVIDER_NOT_SUPPORTED: "Provider is not supported",
|
|
68
|
+
ErrorCode.PROVIDER_RATE_LIMITED: "Provider rate limit exceeded",
|
|
69
|
+
ErrorCode.SYSTEM_INTERNAL_ERROR: "Internal server error",
|
|
70
|
+
ErrorCode.SYSTEM_PROGRESS_CONFLICT: "Another operation is already in progress",
|
|
71
|
+
ErrorCode.AUTH_REQUIRED: "Authentication required",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ErrorDetail(BaseModel):
|
|
76
|
+
"""Error detail embedded in ErrorResponse."""
|
|
77
|
+
|
|
78
|
+
code: ErrorCode
|
|
79
|
+
message: str
|
|
80
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ErrorResponse(BaseModel):
|
|
84
|
+
"""Standard error response envelope.
|
|
85
|
+
|
|
86
|
+
JSON shape: {"error": {"code": "...", "message": "...", "details": {...}}}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
error: ErrorDetail
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def error_factory(
|
|
93
|
+
cls,
|
|
94
|
+
code: ErrorCode,
|
|
95
|
+
message: Optional[str] = None,
|
|
96
|
+
details: Optional[dict[str, Any]] = None,
|
|
97
|
+
) -> "ErrorResponse":
|
|
98
|
+
return cls(
|
|
99
|
+
error=ErrorDetail(
|
|
100
|
+
code=code,
|
|
101
|
+
message=message or DEFAULT_MESSAGES.get(code, code.value),
|
|
102
|
+
details=details or {},
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class KeyManagerError(Exception):
|
|
108
|
+
"""Base exception for all API Key Manager errors."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
code: ErrorCode,
|
|
113
|
+
message: Optional[str] = None,
|
|
114
|
+
details: Optional[dict[str, Any]] = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.code = code
|
|
117
|
+
self.message = message or DEFAULT_MESSAGES.get(code, code.value)
|
|
118
|
+
self.details = details or {}
|
|
119
|
+
super().__init__(self.message)
|
|
120
|
+
|
|
121
|
+
def to_response(self, status_code: Optional[int] = None) -> tuple[int, ErrorResponse]:
|
|
122
|
+
"""Convert to HTTP status code and ErrorResponse body."""
|
|
123
|
+
http_code = status_code or ERROR_STATUS_CODES.get(self.code, 500)
|
|
124
|
+
body = ErrorResponse(
|
|
125
|
+
error=ErrorDetail(
|
|
126
|
+
code=self.code,
|
|
127
|
+
message=self.message,
|
|
128
|
+
details=self.details,
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return http_code, body
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ValidationError(KeyManagerError):
|
|
135
|
+
"""Raised for input validation failures."""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
code: ErrorCode = ErrorCode.VALIDATION_MISSING_KEY,
|
|
140
|
+
message: Optional[str] = None,
|
|
141
|
+
details: Optional[dict[str, Any]] = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
super().__init__(code=code, message=message, details=details)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class StorageError(KeyManagerError):
|
|
147
|
+
"""Raised for storage read/write/encryption failures."""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
code: ErrorCode = ErrorCode.STORAGE_READ_ERROR,
|
|
152
|
+
message: Optional[str] = None,
|
|
153
|
+
details: Optional[dict[str, Any]] = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
super().__init__(code=code, message=message, details=details)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ProviderError(KeyManagerError):
|
|
159
|
+
"""Raised for provider interaction failures."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
code: ErrorCode = ErrorCode.PROVIDER_CHECK_FAILED,
|
|
164
|
+
message: Optional[str] = None,
|
|
165
|
+
details: Optional[dict[str, Any]] = None,
|
|
166
|
+
) -> None:
|
|
167
|
+
super().__init__(code=code, message=message, details=details)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class SystemError(KeyManagerError):
|
|
171
|
+
"""Raised for internal system errors."""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
code: ErrorCode = ErrorCode.SYSTEM_INTERNAL_ERROR,
|
|
176
|
+
message: Optional[str] = None,
|
|
177
|
+
details: Optional[dict[str, Any]] = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
super().__init__(code=code, message=message, details=details)
|
key_manager/i18n.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Internationalization (i18n) module for API Key Manager."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
_I18N_DIR = Path(__file__).parent / "i18n"
|
|
10
|
+
_DEFAULT_LANG = "en"
|
|
11
|
+
_fallback_chain: list[str] = [_DEFAULT_LANG]
|
|
12
|
+
|
|
13
|
+
_translations: dict[str, dict[str, str]] = {}
|
|
14
|
+
_lock = threading.Lock()
|
|
15
|
+
_context = threading.local()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_lang(lang: str) -> dict[str, str]:
|
|
19
|
+
"""Load translations for a language from its JSON file, with caching."""
|
|
20
|
+
with _lock:
|
|
21
|
+
if lang in _translations:
|
|
22
|
+
return _translations[lang]
|
|
23
|
+
path = _I18N_DIR / f"{lang}.json"
|
|
24
|
+
if not path.exists():
|
|
25
|
+
with _lock:
|
|
26
|
+
_translations[lang] = {}
|
|
27
|
+
return {}
|
|
28
|
+
with open(path, encoding="utf-8") as f:
|
|
29
|
+
data: dict[str, str] = json.load(f)
|
|
30
|
+
with _lock:
|
|
31
|
+
_translations[lang] = data
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_current_lang() -> str:
|
|
36
|
+
"""Get the current language from thread-local context, or default."""
|
|
37
|
+
return getattr(_context, "lang", _DEFAULT_LANG)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def set_lang(lang: str) -> None:
|
|
41
|
+
"""Set the current language for the calling thread."""
|
|
42
|
+
_context.lang = lang
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def language_context(lang: str):
|
|
47
|
+
"""Context manager to temporarily set the active language."""
|
|
48
|
+
prev = getattr(_context, "lang", None)
|
|
49
|
+
_context.lang = lang
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
if prev is None:
|
|
54
|
+
_context.lang = _DEFAULT_LANG
|
|
55
|
+
else:
|
|
56
|
+
_context.lang = prev
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_lang_from_header(accept_language: Optional[str]) -> str:
|
|
60
|
+
"""Parse Accept-Language header and return the best matching language.
|
|
61
|
+
|
|
62
|
+
Supports formats like:
|
|
63
|
+
- "zh-CN,zh;q=0.9,en;q=0.8"
|
|
64
|
+
- "en-US,en;q=0.9"
|
|
65
|
+
- "zh"
|
|
66
|
+
- "fr"
|
|
67
|
+
"""
|
|
68
|
+
if not accept_language or not accept_language.strip():
|
|
69
|
+
return _DEFAULT_LANG
|
|
70
|
+
|
|
71
|
+
available = {p.stem for p in _I18N_DIR.glob("*.json")} if _I18N_DIR.exists() else set()
|
|
72
|
+
|
|
73
|
+
candidates: list[tuple[str, float]] = []
|
|
74
|
+
for part in accept_language.split(","):
|
|
75
|
+
part = part.strip()
|
|
76
|
+
if not part:
|
|
77
|
+
continue
|
|
78
|
+
pieces = part.split(";")
|
|
79
|
+
lang_tag = pieces[0].strip().lower()
|
|
80
|
+
quality = 1.0
|
|
81
|
+
if len(pieces) > 1:
|
|
82
|
+
for param in pieces[1:]:
|
|
83
|
+
param = param.strip()
|
|
84
|
+
if param.startswith("q="):
|
|
85
|
+
try:
|
|
86
|
+
quality = float(param[2:])
|
|
87
|
+
except ValueError:
|
|
88
|
+
quality = 0.0
|
|
89
|
+
if lang_tag:
|
|
90
|
+
candidates.append((lang_tag, quality))
|
|
91
|
+
|
|
92
|
+
candidates.sort(key=lambda x: x[1], reverse=True)
|
|
93
|
+
|
|
94
|
+
for lang_tag, _ in candidates:
|
|
95
|
+
if lang_tag in available:
|
|
96
|
+
return lang_tag
|
|
97
|
+
primary = lang_tag.split("-")[0]
|
|
98
|
+
if primary in available:
|
|
99
|
+
return primary
|
|
100
|
+
|
|
101
|
+
return _DEFAULT_LANG
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def t(code: str, lang: Optional[str] = None, **kwargs: Any) -> str:
|
|
105
|
+
"""Translate a message code to the current or specified language.
|
|
106
|
+
|
|
107
|
+
Falls back to English if the code is not found in the requested language.
|
|
108
|
+
Supports {key} placeholder substitution via kwargs.
|
|
109
|
+
"""
|
|
110
|
+
target_lang = lang or _get_current_lang()
|
|
111
|
+
|
|
112
|
+
# Try requested language first
|
|
113
|
+
translations = _load_lang(target_lang)
|
|
114
|
+
message = translations.get(code)
|
|
115
|
+
|
|
116
|
+
# Walk fallback chain if missing
|
|
117
|
+
if message is None and target_lang != _DEFAULT_LANG:
|
|
118
|
+
for fb_lang in _fallback_chain:
|
|
119
|
+
if fb_lang == target_lang:
|
|
120
|
+
continue
|
|
121
|
+
fb_translations = _load_lang(fb_lang)
|
|
122
|
+
message = fb_translations.get(code)
|
|
123
|
+
if message is not None:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
# Ultimate fallback: return the raw code
|
|
127
|
+
if message is None:
|
|
128
|
+
message = code
|
|
129
|
+
|
|
130
|
+
if kwargs:
|
|
131
|
+
try:
|
|
132
|
+
message = message.format(**kwargs)
|
|
133
|
+
except (KeyError, IndexError, ValueError):
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
return message
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def reload_translations() -> None:
|
|
140
|
+
"""Clear the translation cache, forcing reload on next access."""
|
|
141
|
+
with _lock:
|
|
142
|
+
_translations.clear()
|