优化鉴权系统

This commit is contained in:
2026-07-03 21:54:19 +08:00
parent 7e65fadd83
commit 98a92b83fa
11 changed files with 539 additions and 149 deletions
+1 -1
View File
@@ -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
+25 -9
View File
@@ -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`
+7 -3
View File
@@ -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:
+7 -19
View File
@@ -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,
+5 -33
View File
@@ -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
+2
View File
@@ -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",
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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"] == []
+160
View File
@@ -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)
Generated
+64
View File
@@ -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" },