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.
Files changed (73) hide show
  1. api_key_manager-2.1.0.dist-info/METADATA +709 -0
  2. api_key_manager-2.1.0.dist-info/RECORD +73 -0
  3. api_key_manager-2.1.0.dist-info/WHEEL +5 -0
  4. api_key_manager-2.1.0.dist-info/entry_points.txt +2 -0
  5. api_key_manager-2.1.0.dist-info/top_level.txt +1 -0
  6. key_manager/__init__.py +16 -0
  7. key_manager/__main__.py +5 -0
  8. key_manager/api_models.py +358 -0
  9. key_manager/checker.py +51 -0
  10. key_manager/cli.py +270 -0
  11. key_manager/config.py +61 -0
  12. key_manager/core.py +205 -0
  13. key_manager/detector.py +335 -0
  14. key_manager/errors.py +179 -0
  15. key_manager/i18n.py +142 -0
  16. key_manager/logger.py +207 -0
  17. key_manager/model_capabilities.py +412 -0
  18. key_manager/parser.py +153 -0
  19. key_manager/providers/__init__.py +283 -0
  20. key_manager/providers/ai302.py +109 -0
  21. key_manager/providers/anthropic.py +109 -0
  22. key_manager/providers/baichuan.py +97 -0
  23. key_manager/providers/base.py +312 -0
  24. key_manager/providers/cerebras.py +109 -0
  25. key_manager/providers/cohere.py +90 -0
  26. key_manager/providers/cstcloud.py +122 -0
  27. key_manager/providers/dashscope.py +120 -0
  28. key_manager/providers/dashscope_coding.py +122 -0
  29. key_manager/providers/deepseek.py +166 -0
  30. key_manager/providers/dmxapi.py +109 -0
  31. key_manager/providers/doubao.py +109 -0
  32. key_manager/providers/fireworks.py +109 -0
  33. key_manager/providers/google.py +99 -0
  34. key_manager/providers/grok.py +109 -0
  35. key_manager/providers/groq.py +109 -0
  36. key_manager/providers/huggingface.py +54 -0
  37. key_manager/providers/hyperbolic.py +109 -0
  38. key_manager/providers/infini.py +135 -0
  39. key_manager/providers/infini_coding.py +124 -0
  40. key_manager/providers/kimi.py +121 -0
  41. key_manager/providers/kimi_coding.py +124 -0
  42. key_manager/providers/longcat.py +123 -0
  43. key_manager/providers/mimo.py +109 -0
  44. key_manager/providers/mimo_plan.py +140 -0
  45. key_manager/providers/minimax.py +97 -0
  46. key_manager/providers/minimax_plan.py +122 -0
  47. key_manager/providers/mistral.py +109 -0
  48. key_manager/providers/models_registry.py +2901 -0
  49. key_manager/providers/modelscope.py +134 -0
  50. key_manager/providers/nvidia.py +109 -0
  51. key_manager/providers/ocoolai.py +109 -0
  52. key_manager/providers/openai.py +140 -0
  53. key_manager/providers/openrouter.py +119 -0
  54. key_manager/providers/perplexity.py +109 -0
  55. key_manager/providers/poe.py +109 -0
  56. key_manager/providers/ppio.py +109 -0
  57. key_manager/providers/replicate.py +54 -0
  58. key_manager/providers/siliconflow.py +121 -0
  59. key_manager/providers/stepfun.py +132 -0
  60. key_manager/providers/tencent_hunyuan.py +122 -0
  61. key_manager/providers/together.py +134 -0
  62. key_manager/providers/yi.py +97 -0
  63. key_manager/providers/zai.py +109 -0
  64. key_manager/providers/zhipu.py +127 -0
  65. key_manager/providers/zhipu_coding.py +124 -0
  66. key_manager/proxy.py +70 -0
  67. key_manager/ssrf.py +68 -0
  68. key_manager/storage.py +134 -0
  69. key_manager/tester.py +137 -0
  70. key_manager/url_override.py +5 -0
  71. key_manager/validator.py +185 -0
  72. key_manager/web.py +1512 -0
  73. key_manager/webhook.py +257 -0
@@ -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()