diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d0653e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Structure & Module Organization +KiroGate currently contains two implementations: +- `main.ts` + `lib/*.ts`: Deno/TypeScript modular gateway (current primary path). +- `main.py` + `kiro_gateway/*.py`: FastAPI/Python gateway used by Docker deployment. +- `docker-compose.yml`, `Dockerfile`, and `fly.toml`: deployment and runtime config. +- Root-level config includes `deno.json` (tasks/style), `requirements.txt` (Python deps), and `cspell.json`. + +When adding features, keep business logic in `lib/` (TS) or `kiro_gateway/` (Python), and keep entrypoints (`main.ts`, `main.py`) focused on wiring. + +## Build, Test, and Development Commands +- `deno task dev`: run TypeScript server with file watching. +- `deno task start`: run TypeScript server once. +- `deno task check`: static type check for `main.ts` and imports. +- `deno fmt --check && deno lint`: enforce Deno formatting/lint rules. +- `python main.py`: run FastAPI server locally (port `8000`). +- `python -m compileall main.py kiro_gateway`: quick Python syntax validation. +- `docker compose up --build`: run containerized Python deployment. + +## Coding Style & Naming Conventions +- TypeScript: follow `deno fmt` defaults from `deno.json` (2-space indent, single quotes, 120 columns). +- Python: 4-space indent, PEP 8 naming (`snake_case` functions/modules, `PascalCase` classes). +- Prefer descriptive module names (`rateLimiter.ts`, `request_handler.py`) and avoid adding logic-heavy code to route/entry files. +- Use explicit types in TS for public interfaces and request/response conversion boundaries. + +## Testing Guidelines +There is no dedicated `tests/` directory yet. For each non-trivial change: +- Run `deno task check`, `deno fmt --check`, and `deno lint` for TS changes. +- Run `python -m compileall main.py kiro_gateway` for Python changes. +- Smoke test key endpoints manually (e.g., `GET /health`, `POST /v1/chat/completions`). +- If you add test infrastructure, place tests under `tests/` and mirror module names (`test_auth.py`, `translator.test.ts`). + +## Commit & Pull Request Guidelines +Recent history follows Conventional Commit style (`feat:`, `fix:`, `refactor:`). Keep using this format: +- Example: `fix: handle empty streaming chunks in OpenAI adapter`. + +PRs should include: +- concise problem/solution summary, +- linked issue (if available), +- validation steps/commands run, +- screenshots for admin/dashboard UI changes, +- notes on env var or API behavior changes. diff --git a/kiro_gateway/health_checker.py b/kiro_gateway/health_checker.py index 2fcfdc1..33d6160 100644 --- a/kiro_gateway/health_checker.py +++ b/kiro_gateway/health_checker.py @@ -9,6 +9,7 @@ import asyncio from typing import Optional +import httpx from loguru import logger from kiro_gateway.config import settings @@ -74,32 +75,69 @@ async def check_all_tokens(self) -> dict: valid_count = 0 invalid_count = 0 + transient_fail_count = 0 for token in tokens: try: - is_valid = await self.check_token(token.id) - if is_valid: + result = await self.check_token(token.id) + if result["is_valid"]: valid_count += 1 else: - invalid_count += 1 - # Mark token as invalid if it fails - user_db.set_token_status(token.id, "invalid") - logger.warning(f"Token {token.id} marked as invalid") + if result["should_mark_invalid"]: + invalid_count += 1 + user_db.set_token_status(token.id, "invalid") + logger.warning(f"Token {token.id} marked as invalid: {result['error']}") + else: + transient_fail_count += 1 + logger.warning( + f"Token {token.id} health check transient failure, keep active: {result['error']}" + ) except Exception as e: logger.error(f"Failed to check token {token.id}: {e}") - invalid_count += 1 + transient_fail_count += 1 # Small delay between checks to avoid rate limiting await asyncio.sleep(1) - logger.info(f"Health check complete: {valid_count} valid, {invalid_count} invalid") + logger.info( + f"Health check complete: {valid_count} valid, {invalid_count} invalid, " + f"{transient_fail_count} transient_failed" + ) return { "checked": len(tokens), "valid": valid_count, - "invalid": invalid_count + "invalid": invalid_count, + "transient_failed": transient_fail_count, } - async def check_token(self, token_id: int) -> bool: + @staticmethod + def _should_mark_invalid(error: Exception | str) -> bool: + """ + Determine whether a failed health check indicates a permanent credential issue. + + Only permanent auth/credential failures should deactivate tokens. + Transient failures (network/rate limit/5xx) keep token active. + """ + if isinstance(error, httpx.HTTPStatusError): + status = error.response.status_code if error.response else None + if status in (400, 401, 403): + return True + return False + + text = str(error).lower() + permanent_markers = ( + "bad credentials", + "invalid_grant", + "unauthorized", + "forbidden", + "client id is not set", + "client secret is not set", + "refresh token is not set", + "响应中没有 accesstoken", + ) + return any(marker in text for marker in permanent_markers) + + async def check_token(self, token_id: int) -> dict: """ Check a single token's validity. @@ -107,18 +145,28 @@ async def check_token(self, token_id: int) -> bool: token_id: Token ID to check Returns: - True if token is valid, False otherwise + dict: + - is_valid: bool + - should_mark_invalid: bool + - error: str | None """ - # Get decrypted token - refresh_token = user_db.get_decrypted_token(token_id) - if not refresh_token: - user_db.record_health_check(token_id, False, "Failed to decrypt token") - return False + # Get full credentials (supports IDC mode) + credentials = user_db.get_token_credentials(token_id) + if not credentials or not credentials.get("refresh_token"): + err = "Failed to load token credentials" + user_db.record_health_check(token_id, False, err) + return { + "is_valid": False, + "should_mark_invalid": True, + "error": err, + } # Try to get access token try: manager = KiroAuthManager( - refresh_token=refresh_token, + refresh_token=credentials["refresh_token"], + client_id=credentials.get("client_id"), + client_secret=credentials.get("client_secret"), region=settings.region, profile_arn=settings.profile_arn ) @@ -126,15 +174,28 @@ async def check_token(self, token_id: int) -> bool: if access_token: user_db.record_health_check(token_id, True) - return True - else: - user_db.record_health_check(token_id, False, "No access token returned") - return False + return { + "is_valid": True, + "should_mark_invalid": False, + "error": None, + } + + err = "No access token returned" + user_db.record_health_check(token_id, False, err) + return { + "is_valid": False, + "should_mark_invalid": True, + "error": err, + } except Exception as e: error_msg = str(e)[:200] # Truncate long error messages user_db.record_health_check(token_id, False, error_msg) - return False + return { + "is_valid": False, + "should_mark_invalid": self._should_mark_invalid(e), + "error": error_msg, + } # Global health checker instance diff --git a/kiro_gateway/pages.py b/kiro_gateway/pages.py index 4123d54..2ddfdb8 100644 --- a/kiro_gateway/pages.py +++ b/kiro_gateway/pages.py @@ -4542,7 +4542,7 @@ def render_user_page(user) -> str:
⚠️ IDC 模式下所有 Token 共用同一组 Client ID/Secret
+可选:填写后将覆盖导入文件中的 Client ID/Secret;留空则使用每条 Token 自带凭证。
@@ -5538,18 +5538,32 @@ def render_user_page(user) -> str: if (authType === 'idc') {{ const clientId = document.getElementById('donateClientId').value.trim(); const clientSecret = document.getElementById('donateClientSecret').value.trim(); - // 检查 JSON 中是否包含 client_id/client_secret + // 检查 JSON 中是否包含 client_id/client_secret(支持文本输入和文件上传) let jsonHasClientInfo = false; + const hasClientInfoDeep = (obj) => {{ + if (!obj || typeof obj !== 'object') return false; + if (Array.isArray(obj)) return obj.some(hasClientInfoDeep); + const topLevelPair = (obj.client_id || obj.clientId) && (obj.client_secret || obj.clientSecret); + const creds = obj.credentials || obj.credentials_kiro_rs || {{}}; + const nestedPair = (creds.client_id || creds.clientId) && (creds.client_secret || creds.clientSecret); + if (topLevelPair || nestedPair) return true; + return Object.values(obj).some(hasClientInfoDeep); + }}; + const detectJsonClientInfo = (text) => {{ + if (!text) return false; + try {{ + const parsed = JSON.parse(text); + return hasClientInfoDeep(parsed); + }} catch (e) {{ + return false; + }} + }}; if (tokensText) {{ + jsonHasClientInfo = detectJsonClientInfo(tokensText); + }} else if (file) {{ try {{ - const parsed = JSON.parse(tokensText); - const items = Array.isArray(parsed) ? parsed : [parsed]; - jsonHasClientInfo = items.some(item => {{ - const hasTopLevel = (item.client_id || item.clientId) && (item.client_secret || item.clientSecret); - const creds = item.credentials || item.credentials_kiro_rs || {{}}; - const hasNested = (creds.client_id || creds.clientId) && (creds.client_secret || creds.clientSecret); - return hasTopLevel || hasNested; - }}); + const fileText = await file.text(); + jsonHasClientInfo = detectJsonClientInfo(fileText); }} catch (e) {{}} }} // 只有当 JSON 中没有 client 信息时才强制要求手动填写 diff --git a/kiro_gateway/routes.py b/kiro_gateway/routes.py index c0ee445..02e227b 100644 --- a/kiro_gateway/routes.py +++ b/kiro_gateway/routes.py @@ -3476,6 +3476,10 @@ async def get_public_tokens(): { "id": t["id"], "username": t["username"], + "status": t["status"], + "success_count": t["success_count"], + "fail_count": t["fail_count"], + "use_count": t["success_count"] + t["fail_count"], "success_rate": round(t["success_rate"] * 100, 1), "last_used": t["last_used"], } diff --git a/kiro_gateway/token_allocator.py b/kiro_gateway/token_allocator.py index 6d9bd90..c9895c8 100644 --- a/kiro_gateway/token_allocator.py +++ b/kiro_gateway/token_allocator.py @@ -129,13 +129,15 @@ async def _get_manager(self, token: DonatedToken) -> KiroAuthManager: if token.id in self._token_managers: return self._token_managers[token.id] - # 获取解密的 refresh token - refresh_token = user_db.get_decrypted_token(token.id) - if not refresh_token: - raise NoTokenAvailable(f"Failed to decrypt token {token.id}") + # 获取完整凭证(支持 IDC: refresh_token + client_id + client_secret) + credentials = user_db.get_token_credentials(token.id) + if not credentials or not credentials.get("refresh_token"): + raise NoTokenAvailable(f"Failed to load credentials for token {token.id}") manager = KiroAuthManager( - refresh_token=refresh_token, + refresh_token=credentials["refresh_token"], + client_id=credentials.get("client_id"), + client_secret=credentials.get("client_secret"), region=settings.region, profile_arn=settings.profile_arn )