Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
105 changes: 83 additions & 22 deletions kiro_gateway/health_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import asyncio
from typing import Optional

import httpx
from loguru import logger

from kiro_gateway.config import settings
Expand Down Expand Up @@ -74,67 +75,127 @@ 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.

Args:
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
)
access_token = await manager.get_access_token()

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
Expand Down
34 changes: 24 additions & 10 deletions kiro_gateway/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4542,7 +4542,7 @@ def render_user_page(user) -> str:
<div>
<input type="password" id="donateClientSecret" class="w-full px-3 py-2 rounded-lg text-sm" style="background: var(--bg-card); border: 1px solid var(--border);" placeholder="Client Secret">
</div>
<p class="text-xs mt-2" style="color: var(--text-muted);">⚠️ IDC 模式下所有 Token 共用同一组 Client ID/Secret</p>
<p class="text-xs mt-2" style="color: var(--text-muted);">可选:填写后将覆盖导入文件中的 Client ID/Secret;留空则使用每条 Token 自带凭证。</p>
</div>

<!-- 文件上传 -->
Expand Down Expand Up @@ -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 信息时才强制要求手动填写
Expand Down
4 changes: 4 additions & 0 deletions kiro_gateway/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}
Expand Down
12 changes: 7 additions & 5 deletions kiro_gateway/token_allocator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down