优化鉴权系统
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
DEFAULT_SYSTEM_PROMPT=You are a helpful assistant.
|
||||
CHAT_DATA_DIR=data
|
||||
API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret-key"}
|
||||
USER_DB_PATH=data/users.db
|
||||
MAX_TOOL_ROUNDS=5
|
||||
MAX_TOOL_CALLS_PER_TURN=10
|
||||
TOOL_TIMEOUT_SECONDS=5
|
||||
@@ -5,8 +5,9 @@
|
||||
## 代码结构
|
||||
|
||||
- `routes.py`:集中定义所有 HTTP 路由。
|
||||
- `auth.py`:校验 API key 白名单并识别当前用户。
|
||||
- `auth.py`:根据 `user_id` 识别当前用户,校验请求 `X-API-Key`。
|
||||
- `schemas.py`:集中定义 API 请求和响应结构。
|
||||
- `users.py`:基于 SQLite 的用户注册、登录与 API Key 持久化。
|
||||
- `domain.py`:定义会话、消息和工具调用的持久化模型。
|
||||
- `tools.py`:集中定义工具注册表、参数结构和工具函数。
|
||||
- `service.py`:处理对话、Agent 循环和业务规则。
|
||||
@@ -23,12 +24,27 @@ uv run uvicorn main:app --reload
|
||||
|
||||
服务默认运行在 `http://127.0.0.1:8000`,交互式 API 文档位于 `/docs`。
|
||||
|
||||
服务会自动加载项目根目录的 `.env`,已有系统环境变量优先级更高。可配置 `DEEPSEEK_BASE_URL`、`DEEPSEEK_MODEL`、`DEFAULT_SYSTEM_PROMPT`、`CHAT_DATA_DIR` 和工具调用限制,完整示例见 `.env.example`。第一版应只使用一个 Uvicorn worker。
|
||||
服务会自动加载项目根目录的 `.env`,已有系统环境变量优先级更高。可配置 `DEEPSEEK_BASE_URL`、`DEEPSEEK_MODEL`、`DEFAULT_SYSTEM_PROMPT`、`CHAT_DATA_DIR`、`USER_DB_PATH` 和工具调用限制,完整示例见 `.env.example`。第一版应只使用一个 Uvicorn worker。
|
||||
|
||||
所有业务接口都必须通过 `X-API-Key` 请求头提供访问密钥。白名单使用用户到密钥的 JSON 映射:
|
||||
用户名、密码哈希、UUID 及 API Key 保存在 SQLite 中(默认路径 `USER_DB_PATH=data/users.db`,随服务启动自动建表)。所有业务接口都必须通过 `X-API-Key` 请求头提供访问密钥,服务端从数据库中查找比对以识别当前用户。
|
||||
|
||||
```text
|
||||
API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret-key"}
|
||||
## 注册与登录
|
||||
|
||||
注册用户(用户名已存在返回 `409`):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/auth/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"小明","password":"your-password"}'
|
||||
```
|
||||
|
||||
用用户名、密码换取持久化的 API Key(凭据错误返回 `401`):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"小明","password":"your-password"}'
|
||||
# => {"user_id":"...","api_key":"YOUR_API_KEY"}
|
||||
```
|
||||
|
||||
## 使用
|
||||
@@ -37,7 +53,7 @@ API_KEY_WHITELIST={"11111111-1111-4111-8111-111111111111":"replace-with-a-secret
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/sessions \
|
||||
-H 'X-API-Key: replace-with-a-secret-key' \
|
||||
-H 'X-API-Key: YOUR_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"system_prompt":"你是一个简洁的中文助手。"}'
|
||||
```
|
||||
@@ -46,7 +62,7 @@ curl -X POST http://127.0.0.1:8000/sessions \
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/sessions/SESSION_ID/messages \
|
||||
-H 'X-API-Key: replace-with-a-secret-key' \
|
||||
-H 'X-API-Key: YOUR_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content":"你好,请记住我的名字是小明。"}'
|
||||
```
|
||||
@@ -63,14 +79,14 @@ API 返回及存储的每条消息都包含 UTC `created_at`。只有模型完
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/sessions/SESSION_ID/messages \
|
||||
-H 'X-API-Key: replace-with-a-secret-key'
|
||||
-H 'X-API-Key: YOUR_API_KEY'
|
||||
```
|
||||
|
||||
查询当前用户的累计 API 调用次数和 token 用量:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/usage \
|
||||
-H 'X-API-Key: replace-with-a-secret-key'
|
||||
-H 'X-API-Key: YOUR_API_KEY'
|
||||
```
|
||||
|
||||
每个会话 JSON 顶层保存所属 `user_id`、成功聊天次数及累计 token。`/usage` 会扫描并汇总当前用户的全部会话,不使用独立统计文件。创建会话、查询历史和查询用量不计入 `api_calls`。
|
||||
|
||||
@@ -10,11 +10,13 @@ from routes import router
|
||||
from service import ChatService
|
||||
from storage import JsonSessionStorage
|
||||
from tools import ToolRegistry
|
||||
from users import UserStore
|
||||
|
||||
|
||||
def create_app(
|
||||
settings: Settings | None = None,
|
||||
client: AsyncOpenAI | None = None,
|
||||
user_store: UserStore | None = None,
|
||||
) -> FastAPI:
|
||||
resolved_settings = settings or Settings.from_env()
|
||||
|
||||
@@ -29,6 +31,10 @@ def create_app(
|
||||
base_url=resolved_settings.base_url,
|
||||
)
|
||||
|
||||
store = user_store or UserStore(resolved_settings.user_db_path)
|
||||
await store.init()
|
||||
|
||||
app.state.user_store = store
|
||||
app.state.chat_service = ChatService(
|
||||
storage=JsonSessionStorage(resolved_settings.data_dir),
|
||||
client=resolved_client,
|
||||
@@ -38,9 +44,7 @@ def create_app(
|
||||
max_tool_rounds=resolved_settings.max_tool_rounds,
|
||||
max_tool_calls_per_turn=resolved_settings.max_tool_calls_per_turn,
|
||||
)
|
||||
app.state.authenticator = ApiKeyAuthenticator(
|
||||
resolved_settings.api_key_whitelist
|
||||
)
|
||||
app.state.authenticator = ApiKeyAuthenticator(store)
|
||||
yield
|
||||
|
||||
if client is None:
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
from dataclasses import dataclass
|
||||
import hmac
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from users import AuthenticatedUser, UserStore
|
||||
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedUser:
|
||||
user_id: str
|
||||
|
||||
|
||||
class ApiKeyAuthenticator:
|
||||
def __init__(self, whitelist: dict[str, str]) -> None:
|
||||
if not whitelist:
|
||||
raise ValueError("API_KEY_WHITELIST must contain at least one user")
|
||||
self._whitelist = whitelist.copy()
|
||||
def __init__(self, user_store: UserStore) -> None:
|
||||
self._user_store = user_store
|
||||
|
||||
def authenticate(self, api_key: str | None) -> AuthenticatedUser | None:
|
||||
if not api_key:
|
||||
return None
|
||||
for user_id, allowed_key in self._whitelist.items():
|
||||
if hmac.compare_digest(api_key, allowed_key):
|
||||
return AuthenticatedUser(user_id=user_id)
|
||||
return None
|
||||
async def authenticate(self, api_key: str | None) -> AuthenticatedUser | None:
|
||||
return await self._user_store.authenticate(api_key)
|
||||
|
||||
|
||||
async def require_api_key(
|
||||
@@ -33,7 +21,7 @@ async def require_api_key(
|
||||
api_key: Annotated[str | None, Security(api_key_header)],
|
||||
) -> AuthenticatedUser:
|
||||
authenticator: ApiKeyAuthenticator = request.app.state.authenticator
|
||||
user = authenticator.authenticate(api_key)
|
||||
user = await authenticator.authenticate(api_key)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
from uuid import UUID
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
@@ -15,7 +13,7 @@ class Settings:
|
||||
model: str
|
||||
default_system_prompt: str
|
||||
data_dir: Path
|
||||
api_key_whitelist: dict[str, str]
|
||||
user_db_path: Path
|
||||
max_tool_rounds: int = 5
|
||||
max_tool_calls_per_turn: int = 10
|
||||
tool_timeout_seconds: float = 5.0
|
||||
@@ -23,6 +21,7 @@ class Settings:
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
load_dotenv(dotenv_path=Path.cwd() / ".env", override=False)
|
||||
data_dir = Path(os.getenv("CHAT_DATA_DIR", "data"))
|
||||
return cls(
|
||||
port=int(os.getenv("SERVICE_PORT", "8000")),
|
||||
api_key=os.getenv("DEEPSEEK_API_KEY"),
|
||||
@@ -31,38 +30,11 @@ class Settings:
|
||||
default_system_prompt=os.getenv(
|
||||
"DEFAULT_SYSTEM_PROMPT", "You are a helpful assistant."
|
||||
),
|
||||
data_dir=Path(os.getenv("CHAT_DATA_DIR", "data")),
|
||||
api_key_whitelist=_parse_api_key_whitelist(
|
||||
os.getenv("API_KEY_WHITELIST", "{}")
|
||||
),
|
||||
data_dir=data_dir,
|
||||
user_db_path=Path(os.getenv("USER_DB_PATH", str(data_dir / "users.db"))),
|
||||
max_tool_rounds=int(os.getenv("MAX_TOOL_ROUNDS", "5")),
|
||||
max_tool_calls_per_turn=int(
|
||||
os.getenv("MAX_TOOL_CALLS_PER_TURN", "10")
|
||||
),
|
||||
tool_timeout_seconds=float(os.getenv("TOOL_TIMEOUT_SECONDS", "5")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_api_key_whitelist(value: str) -> dict[str, str]:
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("API_KEY_WHITELIST must be valid JSON") from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("API_KEY_WHITELIST must map user UUIDs to API keys")
|
||||
|
||||
normalized: dict[str, str] = {}
|
||||
for user_id, api_key in parsed.items():
|
||||
if not isinstance(user_id, str) or not isinstance(api_key, str) or not api_key:
|
||||
raise ValueError("API_KEY_WHITELIST must map user UUIDs to API keys")
|
||||
try:
|
||||
normalized_user_id = str(UUID(user_id))
|
||||
except ValueError as exc:
|
||||
raise ValueError("API_KEY_WHITELIST user IDs must be valid UUIDs") from exc
|
||||
if normalized_user_id in normalized:
|
||||
raise ValueError("API_KEY_WHITELIST contains duplicate user UUIDs")
|
||||
normalized[normalized_user_id] = api_key
|
||||
|
||||
if len(set(normalized.values())) != len(normalized):
|
||||
raise ValueError("API keys in API_KEY_WHITELIST must be unique")
|
||||
return normalized
|
||||
|
||||
@@ -5,6 +5,8 @@ description = "A minimal persistent DeepSeek chat API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"aiosqlite>=0.20.0",
|
||||
"bcrypt>=4.0.0",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx[socks]>=0.28.0",
|
||||
"openai>=1.55.0",
|
||||
|
||||
@@ -4,6 +4,10 @@ from auth import CurrentUser
|
||||
from schemas import (
|
||||
CreateSessionRequest,
|
||||
CreateSessionResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
SessionHistoryResponse,
|
||||
@@ -11,6 +15,12 @@ from schemas import (
|
||||
)
|
||||
from service import ChatProviderError, ChatService
|
||||
from storage import SessionNotFoundError, SessionStorageError
|
||||
from users import (
|
||||
InvalidCredentialsError,
|
||||
UserStore,
|
||||
UserStoreError,
|
||||
UsernameExistsError,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -20,6 +30,55 @@ def get_chat_service(request: Request) -> ChatService:
|
||||
return request.app.state.chat_service
|
||||
|
||||
|
||||
def get_user_store(request: Request) -> UserStore:
|
||||
return request.app.state.user_store
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auth/register",
|
||||
response_model=RegisterResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
tags=["auth"],
|
||||
)
|
||||
async def register(
|
||||
body: RegisterRequest,
|
||||
request: Request,
|
||||
) -> RegisterResponse:
|
||||
store = get_user_store(request)
|
||||
try:
|
||||
user = await store.register(body.username, body.password)
|
||||
except UsernameExistsError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="username already exists",
|
||||
) from exc
|
||||
except UserStoreError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return RegisterResponse(user_id=user.user_id, created_at=user.created_at)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auth/login",
|
||||
response_model=LoginResponse,
|
||||
tags=["auth"],
|
||||
)
|
||||
async def login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
) -> LoginResponse:
|
||||
store = get_user_store(request)
|
||||
try:
|
||||
user = await store.login(body.username, body.password)
|
||||
except InvalidCredentialsError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid username or password",
|
||||
) from exc
|
||||
except UserStoreError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return LoginResponse(user_id=user.user_id, api_key=user.api_key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions",
|
||||
response_model=CreateSessionResponse,
|
||||
|
||||
+52
@@ -72,3 +72,55 @@ class UserUsageResponse(BaseModel):
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("username must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("password must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("username must not be blank")
|
||||
return value
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def validate_password(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("password must not be blank")
|
||||
return value
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
user_id: str
|
||||
api_key: str
|
||||
+151
-78
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -10,13 +11,20 @@ from openai import APIConnectionError
|
||||
import pytest
|
||||
|
||||
from app import create_app
|
||||
from config import Settings, _parse_api_key_whitelist
|
||||
from config import Settings
|
||||
from service import ChatService
|
||||
from storage import JsonSessionStorage
|
||||
from users import UserStore
|
||||
|
||||
|
||||
TEST_USER_ID = "11111111-1111-4111-8111-111111111111"
|
||||
OTHER_USER_ID = "22222222-2222-4222-8222-222222222222"
|
||||
@dataclass
|
||||
class AppHarness:
|
||||
client: TestClient
|
||||
provider: "FakeClient"
|
||||
test_user_id: str
|
||||
other_user_id: str
|
||||
test_api_key: str
|
||||
other_api_key: str
|
||||
|
||||
|
||||
class FakeCompletions:
|
||||
@@ -43,6 +51,8 @@ class FakeCompletions:
|
||||
total_tokens=12,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self) -> None:
|
||||
self.completions = FakeCompletions()
|
||||
@@ -50,8 +60,14 @@ class FakeClient:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_and_provider(tmp_path: Path) -> tuple[TestClient, FakeClient]:
|
||||
def app_client(tmp_path: Path) -> AppHarness:
|
||||
provider = FakeClient()
|
||||
db_path = tmp_path / "users.db"
|
||||
store = UserStore(db_path)
|
||||
asyncio.run(store.init())
|
||||
test_user = asyncio.run(store.register("test-user", "test-password"))
|
||||
other_user = asyncio.run(store.register("other-user", "other-password"))
|
||||
|
||||
settings = Settings(
|
||||
port=8000,
|
||||
api_key=None,
|
||||
@@ -59,15 +75,19 @@ def client_and_provider(tmp_path: Path) -> tuple[TestClient, FakeClient]:
|
||||
model="deepseek-v4-flash",
|
||||
default_system_prompt="default prompt",
|
||||
data_dir=tmp_path,
|
||||
api_key_whitelist={
|
||||
TEST_USER_ID: "test-api-key",
|
||||
OTHER_USER_ID: "other-api-key",
|
||||
},
|
||||
user_db_path=db_path,
|
||||
)
|
||||
app = create_app(settings=settings, client=provider) # type: ignore[arg-type]
|
||||
app = create_app(settings=settings, client=provider, user_store=store) # type: ignore[arg-type]
|
||||
with TestClient(app) as client:
|
||||
client.headers["X-API-Key"] = "test-api-key"
|
||||
yield client, provider
|
||||
client.headers["X-API-Key"] = test_user.api_key
|
||||
yield AppHarness(
|
||||
client=client,
|
||||
provider=provider,
|
||||
test_user_id=test_user.user_id,
|
||||
other_user_id=other_user.user_id,
|
||||
test_api_key=test_user.api_key,
|
||||
other_api_key=other_user.api_key,
|
||||
)
|
||||
|
||||
|
||||
def create_session(client: TestClient, **body: str) -> str:
|
||||
@@ -81,6 +101,8 @@ def test_settings_load_api_key_from_dotenv(
|
||||
) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False)
|
||||
monkeypatch.delenv("USER_DB_PATH", raising=False)
|
||||
monkeypatch.delenv("CHAT_DATA_DIR", raising=False)
|
||||
(tmp_path / ".env").write_text("DEEPSEEK_API_KEY=test-key\n")
|
||||
|
||||
settings = Settings.from_env()
|
||||
@@ -88,22 +110,8 @@ def test_settings_load_api_key_from_dotenv(
|
||||
assert settings.api_key == "test-key"
|
||||
|
||||
|
||||
def test_api_key_whitelist_requires_and_normalizes_uuid() -> None:
|
||||
parsed = _parse_api_key_whitelist(
|
||||
'{"7A007F92-86D0-4F51-931F-007B9F0B8B0E":"key"}'
|
||||
)
|
||||
|
||||
assert parsed == {
|
||||
"7a007f92-86d0-4f51-931f-007b9f0b8b0e": "key"
|
||||
}
|
||||
with pytest.raises(ValueError, match="valid UUIDs"):
|
||||
_parse_api_key_whitelist('{"custom-name":"key"}')
|
||||
|
||||
|
||||
def test_all_api_endpoints_require_valid_api_key(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
def test_all_api_endpoints_require_valid_api_key(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
del client.headers["X-API-Key"]
|
||||
|
||||
missing = client.post("/sessions")
|
||||
@@ -115,10 +123,93 @@ def test_all_api_endpoints_require_valid_api_key(
|
||||
assert invalid.json() == {"detail": "api_key is required"}
|
||||
|
||||
|
||||
def test_register_creates_user_and_returns_user_id(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={"username": "fresh-user", "password": "fresh-password"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
assert isinstance(body["user_id"], str)
|
||||
assert body["user_id"] != app_client.test_user_id
|
||||
assert body["created_at"].endswith("Z")
|
||||
datetime.fromisoformat(body["created_at"])
|
||||
|
||||
login = client.post(
|
||||
"/auth/login",
|
||||
json={"username": "fresh-user", "password": "fresh-password"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
assert login.json()["user_id"] == body["user_id"]
|
||||
assert isinstance(login.json()["api_key"], str)
|
||||
assert login.json()["api_key"]
|
||||
|
||||
|
||||
def test_register_rejects_duplicate_username_with_409(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
|
||||
response = client.post(
|
||||
"/auth/register",
|
||||
json={"username": "test-user", "password": "any-password"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {"detail": "username already exists"}
|
||||
|
||||
|
||||
def test_register_rejects_blank_fields(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
|
||||
blank_username = client.post(
|
||||
"/auth/register", json={"username": " ", "password": "secret"}
|
||||
)
|
||||
blank_password = client.post(
|
||||
"/auth/register", json={"username": "new-user", "password": " "}
|
||||
)
|
||||
|
||||
assert blank_username.status_code == 422
|
||||
assert blank_password.status_code == 422
|
||||
|
||||
|
||||
def test_login_returns_persistent_api_key(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
|
||||
response = client.post(
|
||||
"/auth/login",
|
||||
json={"username": "test-user", "password": "test-password"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["user_id"] == app_client.test_user_id
|
||||
assert body["api_key"] == app_client.test_api_key
|
||||
|
||||
|
||||
def test_login_invalid_credentials_returns_401(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
|
||||
wrong_password = client.post(
|
||||
"/auth/login",
|
||||
json={"username": "test-user", "password": "wrong"},
|
||||
)
|
||||
unknown_user = client.post(
|
||||
"/auth/login",
|
||||
json={"username": "no-such-user", "password": "anything"},
|
||||
)
|
||||
|
||||
assert wrong_password.status_code == 401
|
||||
assert wrong_password.json() == {"detail": "invalid username or password"}
|
||||
assert unknown_user.status_code == 401
|
||||
assert unknown_user.json() == {"detail": "invalid username or password"}
|
||||
|
||||
|
||||
def test_usage_endpoint_returns_current_user_totals(
|
||||
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
|
||||
app_client: AppHarness, tmp_path: Path
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
client = app_client.client
|
||||
session_id = create_session(client)
|
||||
message = client.post(
|
||||
f"/sessions/{session_id}/messages", json={"content": "hello"}
|
||||
@@ -128,7 +219,7 @@ def test_usage_endpoint_returns_current_user_totals(
|
||||
|
||||
assert message.status_code == 200
|
||||
assert response.status_code == 200
|
||||
assert response.json()["user_id"] == TEST_USER_ID
|
||||
assert response.json()["user_id"] == app_client.test_user_id
|
||||
assert response.json()["api_calls"] == 1
|
||||
assert response.json()["prompt_tokens"] == 10
|
||||
assert response.json()["completion_tokens"] == 2
|
||||
@@ -137,21 +228,19 @@ def test_usage_endpoint_returns_current_user_totals(
|
||||
|
||||
|
||||
def test_create_session_uses_default_prompt(
|
||||
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
|
||||
app_client: AppHarness, tmp_path: Path
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
client = app_client.client
|
||||
session_id = create_session(client)
|
||||
|
||||
data = json.loads((tmp_path / f"{session_id}.json").read_text())
|
||||
assert data["user_id"] == TEST_USER_ID
|
||||
assert data["user_id"] == app_client.test_user_id
|
||||
assert data["system_prompt"] == "default prompt"
|
||||
assert data["messages"] == []
|
||||
|
||||
|
||||
def test_create_session_without_body(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
def test_create_session_without_body(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
|
||||
response = client.post("/sessions")
|
||||
|
||||
@@ -159,10 +248,8 @@ def test_create_session_without_body(
|
||||
assert response.json()["system_prompt"] == "default prompt"
|
||||
|
||||
|
||||
def test_create_session_accepts_custom_prompt(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
def test_create_session_accepts_custom_prompt(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
response = client.post("/sessions", json={"system_prompt": "回答中文"})
|
||||
|
||||
assert response.status_code == 201
|
||||
@@ -170,9 +257,9 @@ def test_create_session_accepts_custom_prompt(
|
||||
|
||||
|
||||
def test_multi_turn_request_contains_full_history(
|
||||
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
|
||||
app_client: AppHarness, tmp_path: Path
|
||||
) -> None:
|
||||
client, provider = client_and_provider
|
||||
client, provider = app_client.client, app_client.provider
|
||||
session_id = create_session(client, system_prompt="system")
|
||||
|
||||
first = client.post(
|
||||
@@ -207,10 +294,8 @@ def test_multi_turn_request_contains_full_history(
|
||||
assert all(message["created_at"].endswith("Z") for message in data["messages"])
|
||||
|
||||
|
||||
def test_invalid_session_and_blank_message(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
def test_invalid_session_and_blank_message(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
|
||||
missing = client.post(
|
||||
"/sessions/00000000-0000-0000-0000-000000000000/messages",
|
||||
@@ -226,9 +311,9 @@ def test_invalid_session_and_blank_message(
|
||||
|
||||
|
||||
def test_upstream_failure_does_not_change_history(
|
||||
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
|
||||
app_client: AppHarness, tmp_path: Path
|
||||
) -> None:
|
||||
client, provider = client_and_provider
|
||||
client, provider = app_client.client, app_client.provider
|
||||
session_id = create_session(client)
|
||||
before = (tmp_path / f"{session_id}.json").read_text()
|
||||
provider.completions.error = APIConnectionError(request=object()) # type: ignore[arg-type]
|
||||
@@ -242,9 +327,9 @@ def test_upstream_failure_does_not_change_history(
|
||||
|
||||
|
||||
def test_corrupt_session_returns_500(
|
||||
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
|
||||
app_client: AppHarness, tmp_path: Path
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
client = app_client.client
|
||||
session_id = create_session(client)
|
||||
(tmp_path / f"{session_id}.json").write_text("not-json")
|
||||
|
||||
@@ -256,9 +341,9 @@ def test_corrupt_session_returns_500(
|
||||
|
||||
|
||||
def test_legacy_messages_without_timestamp_remain_usable(
|
||||
client_and_provider: tuple[TestClient, FakeClient], tmp_path: Path
|
||||
app_client: AppHarness, tmp_path: Path
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
client = app_client.client
|
||||
session_id = create_session(client)
|
||||
path = tmp_path / f"{session_id}.json"
|
||||
data = json.loads(path.read_text())
|
||||
@@ -272,16 +357,14 @@ def test_legacy_messages_without_timestamp_remain_usable(
|
||||
|
||||
assert response.status_code == 200
|
||||
migrated = json.loads(path.read_text())
|
||||
assert migrated["user_id"] == TEST_USER_ID
|
||||
assert migrated["user_id"] == app_client.test_user_id
|
||||
assert all("created_at" in message for message in migrated["messages"])
|
||||
|
||||
|
||||
def test_session_cannot_be_accessed_by_another_user(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
def test_session_cannot_be_accessed_by_another_user(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
session_id = create_session(client)
|
||||
other_headers = {"X-API-Key": "other-api-key"}
|
||||
other_headers = {"X-API-Key": app_client.other_api_key}
|
||||
|
||||
history = client.get(
|
||||
f"/sessions/{session_id}/messages", headers=other_headers
|
||||
@@ -296,10 +379,8 @@ def test_session_cannot_be_accessed_by_another_user(
|
||||
assert message.status_code == 404
|
||||
|
||||
|
||||
def test_stream_parameter_is_rejected(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
) -> None:
|
||||
client, provider = client_and_provider
|
||||
def test_stream_parameter_is_rejected(app_client: AppHarness) -> None:
|
||||
client, provider = app_client.client, app_client.provider
|
||||
session_id = create_session(client)
|
||||
|
||||
response = client.post(
|
||||
@@ -311,17 +392,11 @@ def test_stream_parameter_is_rejected(
|
||||
assert provider.completions.calls == []
|
||||
|
||||
|
||||
def test_get_session_history_returns_all_messages(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
def test_get_session_history_returns_all_messages(app_client: AppHarness) -> None:
|
||||
client = app_client.client
|
||||
session_id = create_session(client, system_prompt="history prompt")
|
||||
client.post(
|
||||
f"/sessions/{session_id}/messages", json={"content": "first"}
|
||||
)
|
||||
client.post(
|
||||
f"/sessions/{session_id}/messages", json={"content": "second"}
|
||||
)
|
||||
client.post(f"/sessions/{session_id}/messages", json={"content": "first"})
|
||||
client.post(f"/sessions/{session_id}/messages", json={"content": "second"})
|
||||
|
||||
response = client.get(f"/sessions/{session_id}/messages")
|
||||
|
||||
@@ -341,15 +416,13 @@ def test_get_session_history_returns_all_messages(
|
||||
|
||||
|
||||
def test_get_session_history_handles_empty_and_missing_sessions(
|
||||
client_and_provider: tuple[TestClient, FakeClient]
|
||||
app_client: AppHarness
|
||||
) -> None:
|
||||
client, _ = client_and_provider
|
||||
client = app_client.client
|
||||
session_id = create_session(client)
|
||||
|
||||
empty = client.get(f"/sessions/{session_id}/messages")
|
||||
missing = client.get(
|
||||
"/sessions/00000000-0000-0000-0000-000000000000/messages"
|
||||
)
|
||||
missing = client.get("/sessions/00000000-0000-0000-0000-000000000000/messages")
|
||||
|
||||
assert empty.status_code == 200
|
||||
assert empty.json()["messages"] == []
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hmac
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import aiosqlite
|
||||
import bcrypt
|
||||
|
||||
|
||||
class UsernameExistsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidCredentialsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UserStoreError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredUser:
|
||||
user_id: str
|
||||
api_key: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthenticatedUser:
|
||||
user_id: str
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
api_key TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class UserStore:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self.db_path = db_path
|
||||
|
||||
async def init(self) -> None:
|
||||
try:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL;")
|
||||
await db.execute("PRAGMA busy_timeout=5000;")
|
||||
await db.execute("PRAGMA foreign_keys=ON;")
|
||||
await db.executescript(_SCHEMA)
|
||||
await db.commit()
|
||||
except aiosqlite.Error as exc:
|
||||
raise UserStoreError("could not initialize user store") from exc
|
||||
|
||||
async def register(self, username: str, password: str) -> RegisteredUser:
|
||||
password_hash = bcrypt.hashpw(
|
||||
password.encode("utf-8"), bcrypt.gensalt()
|
||||
).decode("utf-8")
|
||||
now = datetime.now(UTC)
|
||||
user = RegisteredUser(
|
||||
user_id=str(uuid4()),
|
||||
api_key=secrets.token_urlsafe(32),
|
||||
created_at=now,
|
||||
)
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"INSERT INTO users (user_id, username, password_hash, api_key, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
user.user_id,
|
||||
username,
|
||||
password_hash,
|
||||
user.api_key,
|
||||
now.isoformat(),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
except aiosqlite.IntegrityError as exc:
|
||||
raise UsernameExistsError(username) from exc
|
||||
except aiosqlite.Error as exc:
|
||||
raise UserStoreError("could not register user") from exc
|
||||
return user
|
||||
|
||||
async def login(self, username: str, password: str) -> RegisteredUser:
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT user_id, password_hash, api_key, created_at "
|
||||
"FROM users WHERE username = ?",
|
||||
(username,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
except aiosqlite.Error as exc:
|
||||
raise UserStoreError("could not query user") from exc
|
||||
|
||||
if row is None:
|
||||
_run_constant_time_password_check()
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
|
||||
user_id, password_hash, api_key, created_at = row
|
||||
try:
|
||||
password_ok = bcrypt.checkpw(
|
||||
password.encode("utf-8"), password_hash.encode("utf-8")
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise UserStoreError("stored password hash is corrupt") from exc
|
||||
|
||||
if not password_ok:
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
|
||||
try:
|
||||
parsed_created_at = datetime.fromisoformat(created_at)
|
||||
except ValueError as exc:
|
||||
raise UserStoreError("stored created_at is corrupt") from exc
|
||||
|
||||
return RegisteredUser(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
created_at=parsed_created_at,
|
||||
)
|
||||
|
||||
async def authenticate(self, api_key: str | None) -> AuthenticatedUser | None:
|
||||
if not api_key:
|
||||
return None
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT api_key, user_id FROM users WHERE api_key = ?",
|
||||
(api_key,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
except aiosqlite.Error:
|
||||
return None
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
stored_key, user_id = row
|
||||
if not hmac.compare_digest(api_key, stored_key):
|
||||
return None
|
||||
return AuthenticatedUser(user_id=user_id)
|
||||
|
||||
|
||||
def _run_constant_time_password_check() -> None:
|
||||
"""Perform a dummy bcrypt comparison to keep timing roughly constant.
|
||||
|
||||
Equalizes the response time between an unknown username (no hash to
|
||||
compare against) and a wrong password for a known username, reducing the
|
||||
feasibility of username enumeration via timing.
|
||||
"""
|
||||
dummy_hash = bcrypt.gensalt()
|
||||
bcrypt.checkpw(b"dummy-password", dummy_hash)
|
||||
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
|
||||
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
@@ -32,6 +41,57 @@ wheels = [
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "5.0.0"
|
||||
source = { registry = "https://mirrors.ustc.edu.cn/pypi/simple" }
|
||||
sdist = { url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||
{ url = "https://mirrors.ustc.edu.cn/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.6.17"
|
||||
@@ -363,6 +423,8 @@ name = "simple-chat-api"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "openai" },
|
||||
@@ -377,6 +439,8 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.0" },
|
||||
{ name = "openai", specifier = ">=1.55.0" },
|
||||
|
||||
Reference in New Issue
Block a user