Repository: mirrorange/clove
Branch: main
Commit: 0bdbbb4867d0
Files: 75
Total size: 256.5 KB
Directory structure:
gitextract_wf3w9e88/
├── .dockerignore
├── .github/
│ └── workflows/
│ ├── build-and-publish.yml
│ └── docker-publish.yml
├── .gitignore
├── .gitmodules
├── .python-version
├── Dockerfile
├── Dockerfile.huggingface
├── Dockerfile.pypi
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.md
├── README_en.md
├── app/
│ ├── __init__.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ └── routes/
│ │ ├── accounts.py
│ │ ├── claude.py
│ │ ├── settings.py
│ │ └── statistics.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── account.py
│ │ ├── claude_session.py
│ │ ├── config.py
│ │ ├── error_handler.py
│ │ ├── exceptions.py
│ │ ├── external/
│ │ │ └── claude_client.py
│ │ ├── http_client.py
│ │ └── static.py
│ ├── dependencies/
│ │ ├── __init__.py
│ │ └── auth.py
│ ├── locales/
│ │ ├── en.json
│ │ └── zh.json
│ ├── main.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── claude.py
│ │ ├── internal.py
│ │ └── streaming.py
│ ├── processors/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── claude_ai/
│ │ │ ├── __init__.py
│ │ │ ├── claude_api_processor.py
│ │ │ ├── claude_web_processor.py
│ │ │ ├── context.py
│ │ │ ├── event_parser_processor.py
│ │ │ ├── message_collector_processor.py
│ │ │ ├── model_injector_processor.py
│ │ │ ├── non_streaming_response_processor.py
│ │ │ ├── pipeline.py
│ │ │ ├── stop_sequences_processor.py
│ │ │ ├── streaming_response_processor.py
│ │ │ ├── tavern_test_message_processor.py
│ │ │ ├── token_counter_processor.py
│ │ │ ├── tool_call_event_processor.py
│ │ │ └── tool_result_processor.py
│ │ └── pipeline.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── account.py
│ │ ├── cache.py
│ │ ├── event_processing/
│ │ │ ├── __init__.py
│ │ │ ├── event_parser.py
│ │ │ └── event_serializer.py
│ │ ├── i18n.py
│ │ ├── oauth.py
│ │ ├── session.py
│ │ └── tool_call.py
│ └── utils/
│ ├── __init__.py
│ ├── logger.py
│ ├── messages.py
│ └── retry.py
├── docker-compose.yml
├── pyproject.toml
├── scripts/
│ └── build_wheel.py
└── tests/
└── test_claude_request_models.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
# Git
.git/
.gitignore
.gitattributes
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
.pytest_cache/
.coverage
.coverage.*
.cache
htmlcov/
.tox/
.nox/
*.cover
*.py,cover
.hypothesis/
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
.ruff_cache/
# uv.lock 需要保留(uv 构建需要)
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.envrc
# Node.js / Frontend
**/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
lerna-debug.log*
.npm
.eslintcache
.stylelintcache
.node_repl_history
*.tsbuildinfo
.yarn/
.pnp.*
# Frontend build outputs (will be built in Docker)
front/dist/
front/build/
app/static/assets/
app/static/index.html
app/static/vite.svg
# IDEs and editors
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
*.sublime-project
*.sublime-workspace
.project
.classpath
.c9/
*.launch
.settings/
*.iml
.cursorignore
.cursorindexingignore
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Desktop.ini
# Documentation
docs/
*.md
!README.md
LICENSE
# Testing
coverage/
.nyc_output/
test/
tests/
*.test.js
*.spec.js
__tests__/
# Logs
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Temporary files
*.tmp
*.temp
.tmp/
.temp/
tmp/
temp/
# Local data (contains sensitive information)
data/
/data/
*.json
!package.json
!tsconfig*.json
!components.json
!app/locales/*.json
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
Jenkinsfile
# Docker files
Dockerfile*
docker-compose*.yml
.dockerignore
# Makefile and scripts
Makefile
scripts/
# Python packaging files
MANIFEST.in
setup.py
setup.cfg
# Miscellaneous
*.bak
*.orig
*.rej
.cache/
================================================
FILE: .github/workflows/build-and-publish.yml
================================================
name: Build and Publish to PyPI
on:
release:
types: [published]
workflow_dispatch:
inputs:
publish_to_pypi:
description: "Publish to PyPI"
required: true
default: false
type: boolean
publish_to_test_pypi:
description: "Publish to Test PyPI"
required: true
default: false
type: boolean
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install build wheel
- name: Build frontend and wheel
run: |
python scripts/build_wheel.py
- name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
name: python-package-distributions
path: dist/
publish-to-pypi:
name: Publish to PyPI
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_to_pypi == 'true')
needs:
- build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/clove-proxy
permissions:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
publish-to-testpypi:
name: Publish to TestPyPI
if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish_to_test_pypi == 'true'
needs:
- build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/clove-proxy
permissions:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Publish distribution to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
================================================
FILE: .github/workflows/docker-publish.yml
================================================
name: Docker Build and Push
on:
push:
branches:
- main
tags:
- "v*"
pull_request:
branches:
- main
workflow_dispatch:
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
IMAGE_NAME: mirrorange/clove
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ env.DOCKER_HUB_USERNAME }}
password: ${{ env.DOCKER_HUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run security scan
if: github.event_name != 'pull_request'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
format: "sarif"
output: "trivy-results.sarif"
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: "trivy-results.sarif"
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Data
/data/
# Built frontend static files
app/static/
================================================
FILE: .gitmodules
================================================
[submodule "front"]
path = front
url = https://github.com/mirrorange/clove-front.git
================================================
FILE: .python-version
================================================
3.13
================================================
FILE: Dockerfile
================================================
# Multi-stage Dockerfile for Clove (uv version)
# =============================================================================
# Stage 1: Build frontend
# =============================================================================
FROM node:20-alpine AS frontend-builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app/front
# Copy frontend package files
COPY front/package.json front/pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy frontend source
COPY front/ ./
# Build frontend
RUN pnpm run build
# =============================================================================
# Stage 2: Build Python application with uv
# =============================================================================
FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS app
# uv optimization environment variables
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /app
# Step 1: Copy dependency files only (leverage Docker layer caching)
COPY pyproject.toml uv.lock ./
# Install dependencies (without installing the project itself)
# --locked: Use lockfile for consistency
# --no-install-project: Only install dependencies, not the project
# --no-dev: Skip dev dependencies
# --extra rnet --extra curl: Install optional dependency groups
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project --no-dev --extra rnet --extra curl
# Step 2: Copy application code and README.md (required by pyproject.toml)
COPY app/ ./app/
COPY README.md ./
# Step 3: Copy frontend build artifacts (required by pyproject.toml force-include)
COPY --from=frontend-builder /app/front/dist ./app/static
# Step 4: Install the project itself
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev --extra rnet --extra curl
# Create data directory
RUN mkdir -p /data
# Activate virtual environment (add .venv/bin to PATH)
ENV PATH="/app/.venv/bin:$PATH"
# Environment variables
ENV DATA_FOLDER=/data \
HOST=0.0.0.0 \
PORT=5201
# Expose port
EXPOSE 5201
# Reset ENTRYPOINT (uv image default is uv)
ENTRYPOINT []
# Run the application
CMD ["python", "-m", "app.main"]
================================================
FILE: Dockerfile.huggingface
================================================
# Simplified Dockerfile for Clove - For Huggingface Spaces
FROM python:3.11-slim
WORKDIR /app
# Install clove-proxy from PyPI
RUN pip install --no-cache-dir "clove-proxy[rnet]"
# Environment variables
ENV NO_FILESYSTEM_MODE=true
ENV HOST=0.0.0.0
ENV PORT=${PORT:-7860}
# Expose port
EXPOSE ${PORT:-7860}
# Run the application using the installed script
CMD ["clove"]
================================================
FILE: Dockerfile.pypi
================================================
# Simplified Dockerfile for Clove - Install from PyPI
FROM python:3.11-slim
WORKDIR /app
# Install clove-proxy from PyPI
RUN pip install --no-cache-dir "clove-proxy[rnet]"
# Create data directory
RUN mkdir -p /data
# Environment variables
ENV DATA_FOLDER=/data
ENV HOST=0.0.0.0
ENV PORT=${PORT:-5201}
# Expose port
EXPOSE ${PORT:-5201}
# Run the application using the installed script
CMD ["clove"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 orange
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: MANIFEST.in
================================================
# Include all static files
recursive-include app/static *
# Include locale files
recursive-include app/locales *.json
# Include documentation
include README.md
include LICENSE
# Exclude development files
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
recursive-exclude * .DS_Store
global-exclude *.log
global-exclude *.tmp
global-exclude .git*
# Exclude test files
recursive-exclude tests *
recursive-exclude * test_*
# Exclude frontend source
recursive-exclude front *
# Exclude data directory
recursive-exclude data *
================================================
FILE: Makefile
================================================
.PHONY: help build build-frontend build-wheel install install-dev clean run test
# Default target
help:
@echo "Available commands:"
@echo " make build - Build frontend and create Python wheel"
@echo " make build-frontend - Build only the frontend"
@echo " make build-wheel - Build only the Python wheel"
@echo " make install - Build and install the package"
@echo " make install-dev - Install in development mode"
@echo " make clean - Clean build artifacts"
@echo " make run - Run the application (development)"
@echo " make test - Run tests"
# Build everything
build:
@python scripts/build_wheel.py
# Build only frontend
build-frontend:
@cd front && pnpm install && pnpm run build
@rm -rf app/static
@cp -r front/dist app/static
@echo "✓ Frontend build complete"
# Build only wheel
build-wheel:
@python scripts/build_wheel.py --skip-frontend
# Build and install
install: build
@pip install dist/*.whl
@echo "✓ Clove installed successfully"
@echo "Run 'clove' to start the application"
# Install in development mode
install-dev:
@pip install -e .
@echo "✓ Clove installed in development mode"
# Clean build artifacts
clean:
@rm -rf dist build *.egg-info
@rm -rf app/__pycache__ app/**/__pycache__
@rm -rf .pytest_cache .ruff_cache
@find . -type f -name "*.pyc" -delete
@find . -type f -name "*.pyo" -delete
@echo "✓ Cleaned build artifacts"
# Run the application (development mode)
run:
@python -m app.main
================================================
FILE: README.md
================================================
# Clove 🍀
[](LICENSE)
[](https://www.python.org/downloads/)
[](https://fastapi.tiangolo.com)
**全力以赴的 Claude 反向代理 ✨**
[English](./README_en.md) | [简体中文](#)
## 🌟 这是什么?
Clove 是一个让你能够通过标准 Claude API 访问 Claude.ai 的反向代理工具。简单来说,它让各种 AI 应用都能连接上 Claude!
**最大亮点**:Clove 是首个支持通过 OAuth 认证访问 Claude 官方 API 的反向代理(就是 Claude Code 用的那个)!这意味着你能享受到完整的 Claude API 功能,包括原生系统消息和预填充等高级特性。
## 🚀 快速开始
只需要三步,就能开始使用:
### 1. 安装 Python
确保你的电脑上有 Python 3.13 或更高版本
### 2. 安装 Clove
```bash
pip install "clove-proxy[rnet]"
```
### 3. 启动!
```bash
clove
```
启动后会在控制台显示一个随机生成的临时管理密钥。登录管理页面后别忘了添加自己的密钥哦!
### 4. 配置账户
打开浏览器访问:http://localhost:5201
使用刚才的管理密钥登录,然后就可以添加你的 Claude 账户了~
## ✨ 核心功能
### 🔐 双模式运行
- **OAuth 模式**:优先使用,可以访问 Claude API 的全部功能
- **网页反代模式**:当 OAuth 不可用时自动切换,通过模拟 Claude.ai 网页版实现
### 🎯 超高兼容性
与其他反代工具(如 Clewd)相比,Clove 的兼容性非常出色:
- ✅ 完全支持 SillyTavern
- ✅ 支持绝大部分使用 Claude API 的应用
- ✅ 甚至支持 Claude Code 本身!
### 🛠️ 功能增强
#### 对于 OAuth 模式
- 完全访问 Claude API 的全部功能
- 支持原生系统消息
- 支持预填充功能
- 性能更好,更稳定
#### 对于 Claude.ai 网页反代模式
Clove 处理了 Claude.ai 网页版与 API 的各种差异:
- 图片上传支持
- 扩展思考(思维链)支持
即使是通过网页反代,Clove 也能让你使用原本不支持的功能:
- 工具调用(Function Calling)
- 停止序列(Stop Sequences)
- Token 计数(估算值)
- 非流式传输
Clove 尽可能让 Claude.ai 网页反代更接近 API,以期在所有应用程序中获得无缝体验。
### 🎨 友好的管理界面
- 现代化的 Web 管理界面
- 无需编辑配置文件
- 所有设置都能在管理页面上完成
- 自动管理用户配额和状态
### 🔄 智能功能
- **自动 OAuth 认证**:通过 Cookie 自动完成,无需手动登录 Claude Code
- **智能切换**:自动在 OAuth 和 Claude.ai 网页反代之间切换
- **配额管理**:超出配额时自动标记并在重置时恢复
## ⚠️ 局限性
### 1. Android Termux 用户注意
Clove 依赖 `curl_cffi` 来请求 claude.ai,但这个依赖无法在 Termux 上运行。
**解决方案**:
- 使用不含 curl_cffi 的版本:`pip install clove-proxy`
- ✅ 通过 OAuth 访问 Claude API(需要在管理页面手动完成认证)
- ❌ 无法使用网页反代功能
- ❌ 无法自动完成 OAuth 认证
- 使用反向代理/镜像(如 fuclaude)
- ✅ 可以使用全部功能
- ❌ 需要额外的服务器(既然有搭建镜像的服务器,为什么要在 Termux 上部署呢 www)
### 2. 工具调用限制
如果你使用网页反代模式,避免接入会**大量并行执行工具调用**的应用。
- Clove 需要保持与 Claude.ai 的连接等待工具调用结果
- 过多并行调用会耗尽连接导致失败
- OAuth 模式不受此限制
### 3. 提示结构限制
当 Clove 使用网页反代时,Claude.ai 会在提示中添加额外的系统提示词和文件上传结构。当使用对结构要求高的提示词(如 RP 预设)时:
- 你可以预估请求将通过何种方式进行。在默认配置下:
- 使用 Free 账户时,所有请求通过 Claude.ai 网页反代
- 使用 Pro/Max 账户时,所有请求通过 Claude API 进行
- 若存在多账户,Clove 始终优先使用可访问该模型 API 的账户
- 请选择与请求方式兼容的提示词
## 🔧 高级配置
### 环境变量
虽然大部分配置都能在管理界面完成,但你也可以通过环境变量进行设置:
```bash
# 端口配置
PORT=5201
# 管理密钥(不设置则自动生成)
ADMIN_API_KEYS==your-secret-key
# Claude.ai Cookie
COOKIES=sessionKey=your-session-key
```
更多配置请见 `.env.example` 文件。
### API 使用
配置完成后,你可以像使用标准 Claude API 一样使用 Clove:
```python
import anthropic
client = anthropic.Anthropic(
base_url="http://localhost:5201",
api_key="your-api-key" # 在管理界面创建
)
response = client.messages.create(
model="claude-opus-4-20250514",
messages=[{"role": "user", "content": "Hello, Claude!"}],
max_tokens=1024,
)
```
## 🤝 贡献
欢迎贡献代码!如果你有好的想法或发现了问题:
1. Fork 这个项目
2. 创建你的功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交你的修改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开一个 Pull Request
## 📄 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 🙏 致谢
- [Anthropic Claude](https://www.anthropic.com/claude) - ~~可爱的小克~~ 强大的 AI 助手
- [Clewd](https://github.com/teralomaniac/clewd/) - 初代 Claude.ai 反向代理
- [ClewdR](https://github.com/Xerxes-2/clewdr) - 高性能 Claude.ai 反向代理
- [FastAPI](https://fastapi.tiangolo.com/) - 现代、快速的 Web 框架
- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
- [Shadcn UI](https://ui.shadcn.com/) - 现代化的 UI 组件库
- [Vite](https://vitejs.dev/) - 现代化的前端构建工具
- [React](https://reactjs.org/) - JavaScript 库
## ⚠️ 免责声明
本项目仅供学习和研究使用。使用本项目时,请遵守相关服务的使用条款。作者不对任何滥用或违反服务条款的行为负责。
## 📮 联系方式
如有问题或建议,欢迎通过以下方式联系:
- 提交 [Issue](https://github.com/mirrorange/clove/issues)
- 发送 Pull Request
- 发送邮件至:orange@freesia.ink
## 🌸 关于 Clove
丁香,桃金娘科蒲桃属植物,是一种常见的香料,也可用作中药。丁香(Clove)与丁香花(Syringa)是两种不同的植物哦~在本项目中,Clove 更接近 Claude 和 love 的合成词呢!
---
Made with ❤️ by 🍊
================================================
FILE: README_en.md
================================================
# Clove 🍀
[](LICENSE)
[](https://www.python.org/downloads/)
[](https://fastapi.tiangolo.com)
**The all-in-one Claude reverse proxy ✨**
[English](#) | [简体中文](./README.md)
## 🌟 What is this?
Clove is a reverse proxy tool that lets you access Claude.ai through a standard API. In simple terms, it allows various AI applications to connect to Claude!
**The biggest highlight**: Clove is the first reverse proxy to support accessing Claude's official API through OAuth authentication (the same one Claude Code uses)! This means you get the full Claude API experience, including advanced features like native system messages and prefilling.
## 🚀 Quick Start
Just three steps to get started:
### 1. Install Python
Make sure you have Python 3.13 or higher on your computer
### 2. Install Clove
```bash
pip install "clove-proxy[rnet]"
```
### 3. Launch!
```bash
clove
```
After starting, you'll see a randomly generated temporary admin key in the console. Don't forget to add your own key after logging into the admin panel!
### 4. Configure Your Account
Open your browser and go to: http://localhost:5201
Log in with the admin key from earlier, then you can add your Claude account~
## ✨ Core Features
### 🔐 Dual Mode Operation
- **OAuth Mode**: Preferred method, gives you access to all Claude API features
- **Web Proxy Mode**: Automatically switches when OAuth is unavailable, works by emulating the Claude.ai web interface
### 🎯 Outstanding Compatibility
Compared to other proxy tools (like Clewd), Clove offers exceptional compatibility:
- ✅ Full support for SillyTavern
- ✅ Works with most applications that use the Claude API
- ✅ Even supports Claude Code itself!
### 🛠️ Enhanced Features
#### For OAuth Mode
- Complete access to all Claude API features
- Native system message support
- Prefilling support
- Better performance and stability
#### For Claude.ai Web Proxy Mode
Clove handles all the differences between Claude.ai web version and the API:
- Image upload support
- Extended thinking (chain of thought) support
Even through web proxy, Clove enables features that weren't originally supported:
- Function Calling
- Stop Sequences
- Token counting (estimated)
- Non-streaming responses
Clove strives to make the Claude.ai web proxy as API-like as possible for a seamless experience across all applications.
### 🎨 Friendly Admin Interface
- Modern web management interface
- No need to edit config files
- All settings can be configured in the admin panel
- Automatic user quota and status management
### 🔄 Smart Features
- **Automatic OAuth Authentication**: Completed automatically through cookies, no manual Claude Code login needed
- **Intelligent Switching**: Automatically switches between OAuth and Claude.ai web proxy
- **Quota Management**: Automatically flags when quota is exceeded and restores when reset
## ⚠️ Limitations
### 1. Android Termux Users Note
Clove depends on `curl_cffi` to request claude.ai, but this dependency doesn't work on Termux.
**Solutions**:
- Use the version without curl_cffi: `pip install clove-proxy`
- ✅ Access Claude API through OAuth (requires manual authentication in admin panel)
- ❌ Cannot use web proxy features
- ❌ Cannot auto-complete OAuth authentication
- Use a reverse proxy/mirror (like fuclaude)
- ✅ Can use all features
- ❌ Requires an additional server (but if you have a server for mirroring, why deploy on Termux? lol)
### 2. Tool Calling Limitations
If you're using web proxy mode, avoid connecting applications that perform **many parallel tool calls**.
- Clove needs to maintain connections with Claude.ai while waiting for tool call results
- Too many parallel calls will exhaust connections and cause failures
- OAuth mode is not affected by this limitation
### 3. Prompt Structure Limitations
When Clove uses web proxy, Claude.ai adds extra system prompts and file upload structures to your prompts. When using prompts with strict structural requirements (like RP presets):
- You can predict which method your request will use. With default settings:
- Free accounts: All requests go through Claude.ai web proxy
- Pro/Max accounts: All requests use Claude API
- With multiple accounts, Clove always prioritizes accounts with API access for the requested model
- Choose prompts compatible with your request method
## 🔧 Advanced Configuration
### Environment Variables
While most settings can be configured in the admin interface, you can also use environment variables:
```bash
# Port configuration
PORT=5201
# Admin key (auto-generated if not set)
ADMIN_API_KEYS=your-secret-key
# Claude.ai Cookie
COOKIES=sessionKey=your-session-key
```
See `.env.example` for more configuration options.
### API Usage
Once configured, you can use Clove just like the standard Claude API:
```python
import anthropic
client = anthropic.Anthropic(
base_url="http://localhost:5201",
api_key="your-api-key" # Create this in the admin panel
)
response = client.messages.create(
model="claude-opus-4-20250514",
messages=[{"role": "user", "content": "Hello, Claude!"}],
max_tokens=1024,
)
```
## 🤝 Contributing
Contributions are welcome! If you have great ideas or found issues:
1. Fork this project
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
- [Anthropic Claude](https://www.anthropic.com/claude) - ~~Adorable little Claude~~ Powerful AI assistant
- [Clewd](https://github.com/teralomaniac/clewd/) - The original Claude.ai reverse proxy
- [ClewdR](https://github.com/Xerxes-2/clewdr) - High-performance Claude.ai reverse proxy
- [FastAPI](https://fastapi.tiangolo.com/) - Modern, fast web framework
- [Tailwind CSS](https://tailwindcss.com/) - CSS framework
- [Shadcn UI](https://ui.shadcn.com/) - Modern UI component library
- [Vite](https://vitejs.dev/) - Modern frontend build tool
- [React](https://reactjs.org/) - JavaScript library
## ⚠️ Disclaimer
This project is for learning and research purposes only. When using this project, please comply with the terms of service of the relevant services. The author is not responsible for any misuse or violations of service terms.
## 📮 Contact
If you have questions or suggestions, feel free to reach out:
- Submit an [Issue](https://github.com/mirrorange/clove/issues)
- Send a Pull Request
- Email: orange@freesia.ink
## 🌸 About Clove
Clove is a plant from the Myrtaceae family's Syzygium genus, commonly used as a spice and in traditional medicine. Clove (丁香, the spice) and lilac flowers (丁香花, Syringa) are two different plants! In this project, the name Clove is actually a blend of "Claude" and "love"!
---
Made with ❤️ by 🍊
================================================
FILE: app/__init__.py
================================================
__version__ = "0.1.0"
================================================
FILE: app/api/__init__.py
================================================
================================================
FILE: app/api/main.py
================================================
from fastapi import APIRouter
from app.api.routes import claude, accounts, settings, statistics
api_router = APIRouter()
api_router.include_router(claude.router, prefix="/v1", tags=["Claude API"])
api_router.include_router(
accounts.router, prefix="/api/admin/accounts", tags=["Account Management"]
)
api_router.include_router(
settings.router, prefix="/api/admin/settings", tags=["Settings Management"]
)
api_router.include_router(
statistics.router, prefix="/api/admin/statistics", tags=["Statistics"]
)
================================================
FILE: app/api/routes/accounts.py
================================================
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from uuid import UUID
import time
from app.core.exceptions import OAuthExchangeError
from app.dependencies.auth import AdminAuthDep
from app.services.account import account_manager
from app.core.account import AuthType, AccountStatus, OAuthToken
from app.services.oauth import oauth_authenticator
class OAuthTokenCreate(BaseModel):
access_token: str
refresh_token: str
expires_at: float
class AccountCreate(BaseModel):
cookie_value: Optional[str] = None
oauth_token: Optional[OAuthTokenCreate] = None
organization_uuid: Optional[UUID] = None
capabilities: Optional[List[str]] = None
class AccountUpdate(BaseModel):
cookie_value: Optional[str] = None
oauth_token: Optional[OAuthTokenCreate] = None
capabilities: Optional[List[str]] = None
status: Optional[AccountStatus] = None
class OAuthCodeExchange(BaseModel):
organization_uuid: UUID
code: str
pkce_verifier: str
capabilities: Optional[List[str]] = None
class AccountResponse(BaseModel):
organization_uuid: str
capabilities: Optional[List[str]]
cookie_value: Optional[str] = Field(None, description="Masked cookie value")
status: AccountStatus
auth_type: AuthType
is_pro: bool
is_max: bool
has_oauth: bool
last_used: str
resets_at: Optional[str] = None
router = APIRouter()
@router.get("", response_model=List[AccountResponse])
async def list_accounts(_: AdminAuthDep):
"""List all accounts."""
accounts = []
for org_uuid, account in account_manager._accounts.items():
accounts.append(
AccountResponse(
organization_uuid=org_uuid,
capabilities=account.capabilities,
cookie_value=account.cookie_value[:20] + "..."
if account.cookie_value
else None,
status=account.status,
auth_type=account.auth_type,
is_pro=account.is_pro,
is_max=account.is_max,
has_oauth=account.oauth_token is not None,
last_used=account.last_used.isoformat(),
resets_at=account.resets_at.isoformat() if account.resets_at else None,
)
)
return accounts
@router.get("/{organization_uuid}", response_model=AccountResponse)
async def get_account(organization_uuid: str, _: AdminAuthDep):
"""Get a specific account by organization UUID."""
if organization_uuid not in account_manager._accounts:
raise HTTPException(status_code=404, detail="Account not found")
account = account_manager._accounts[organization_uuid]
return AccountResponse(
organization_uuid=organization_uuid,
capabilities=account.capabilities,
cookie_value=account.cookie_value[:20] + "..."
if account.cookie_value
else None,
status=account.status,
auth_type=account.auth_type,
is_pro=account.is_pro,
is_max=account.is_max,
has_oauth=account.oauth_token is not None,
last_used=account.last_used.isoformat(),
resets_at=account.resets_at.isoformat() if account.resets_at else None,
)
@router.post("", response_model=AccountResponse)
async def create_account(account_data: AccountCreate, _: AdminAuthDep):
"""Create a new account."""
oauth_token = None
if account_data.oauth_token:
oauth_token = OAuthToken(
access_token=account_data.oauth_token.access_token,
refresh_token=account_data.oauth_token.refresh_token,
expires_at=account_data.oauth_token.expires_at,
)
account = await account_manager.add_account(
cookie_value=account_data.cookie_value,
oauth_token=oauth_token,
organization_uuid=str(account_data.organization_uuid),
capabilities=account_data.capabilities,
)
return AccountResponse(
organization_uuid=account.organization_uuid,
capabilities=account.capabilities,
cookie_value=account.cookie_value[:20] + "..."
if account.cookie_value
else None,
status=account.status,
auth_type=account.auth_type,
is_pro=account.is_pro,
is_max=account.is_max,
has_oauth=account.oauth_token is not None,
last_used=account.last_used.isoformat(),
resets_at=account.resets_at.isoformat() if account.resets_at else None,
)
@router.put("/{organization_uuid}", response_model=AccountResponse)
async def update_account(
organization_uuid: str, account_data: AccountUpdate, _: AdminAuthDep
):
"""Update an existing account."""
if organization_uuid not in account_manager._accounts:
raise HTTPException(status_code=404, detail="Account not found")
account = account_manager._accounts[organization_uuid]
# Update fields if provided
if account_data.cookie_value is not None:
# Remove old cookie mapping if exists
if (
account.cookie_value
and account.cookie_value in account_manager._cookie_to_uuid
):
del account_manager._cookie_to_uuid[account.cookie_value]
account.cookie_value = account_data.cookie_value
account_manager._cookie_to_uuid[account_data.cookie_value] = organization_uuid
if account_data.oauth_token is not None:
account.oauth_token = OAuthToken(
access_token=account_data.oauth_token.access_token,
refresh_token=account_data.oauth_token.refresh_token,
expires_at=account_data.oauth_token.expires_at,
)
# Update auth type based on what's available
if account.cookie_value and account.oauth_token:
account.auth_type = AuthType.BOTH
elif account.oauth_token:
account.auth_type = AuthType.OAUTH_ONLY
else:
account.auth_type = AuthType.COOKIE_ONLY
if account_data.capabilities is not None:
account.capabilities = account_data.capabilities
if account_data.status is not None:
account.status = account_data.status
if account.status == AccountStatus.VALID:
account.resets_at = None
# Save changes
account_manager.save_accounts()
return AccountResponse(
organization_uuid=organization_uuid,
capabilities=account.capabilities,
cookie_value=account.cookie_value[:20] + "..."
if account.cookie_value
else None,
status=account.status,
auth_type=account.auth_type,
is_pro=account.is_pro,
is_max=account.is_max,
has_oauth=account.oauth_token is not None,
last_used=account.last_used.isoformat(),
resets_at=account.resets_at.isoformat() if account.resets_at else None,
)
@router.delete("/{organization_uuid}")
async def delete_account(organization_uuid: str, _: AdminAuthDep):
"""Delete an account."""
if organization_uuid not in account_manager._accounts:
raise HTTPException(status_code=404, detail="Account not found")
await account_manager.remove_account(organization_uuid)
return {"message": "Account deleted successfully"}
@router.post("/oauth/exchange", response_model=AccountResponse)
async def exchange_oauth_code(exchange_data: OAuthCodeExchange, _: AdminAuthDep):
"""Exchange OAuth authorization code for tokens and create account."""
# Exchange code for tokens
token_data = await oauth_authenticator.exchange_token(
exchange_data.code, exchange_data.pkce_verifier
)
if not token_data:
raise OAuthExchangeError()
# Create OAuth token object
oauth_token = OAuthToken(
access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"],
expires_at=time.time() + token_data["expires_in"],
)
# Create account with OAuth token
account = await account_manager.add_account(
oauth_token=oauth_token,
organization_uuid=str(exchange_data.organization_uuid),
capabilities=exchange_data.capabilities,
)
return AccountResponse(
organization_uuid=account.organization_uuid,
capabilities=account.capabilities,
cookie_value=None,
status=account.status,
auth_type=account.auth_type,
is_pro=account.is_pro,
is_max=account.is_max,
has_oauth=True,
last_used=account.last_used.isoformat(),
resets_at=account.resets_at.isoformat() if account.resets_at else None,
)
================================================
FILE: app/api/routes/claude.py
================================================
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse, JSONResponse
from tenacity import (
retry,
retry_if_exception,
stop_after_attempt,
wait_fixed,
)
from app.core.config import settings
from app.core.exceptions import NoResponseError
from app.dependencies.auth import AuthDep
from app.models.claude import MessagesAPIRequest
from app.processors.claude_ai import ClaudeAIContext
from app.processors.claude_ai.pipeline import ClaudeAIPipeline
from app.utils.retry import is_retryable_error, log_before_sleep
router = APIRouter()
@router.post("/messages", response_model=None)
@retry(
retry=retry_if_exception(is_retryable_error),
stop=stop_after_attempt(settings.retry_attempts),
wait=wait_fixed(settings.retry_interval),
before_sleep=log_before_sleep,
reraise=True,
)
async def create_message(
request: Request, messages_request: MessagesAPIRequest, _: AuthDep
) -> StreamingResponse | JSONResponse:
context = ClaudeAIContext(
original_request=request,
messages_api_request=messages_request,
)
context = await ClaudeAIPipeline().process(context)
if not context.response:
raise NoResponseError()
return context.response
================================================
FILE: app/api/routes/settings.py
================================================
import os
import json
from typing import List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, HttpUrl
from app.dependencies.auth import AdminAuthDep
from app.core.config import Settings, settings
class SettingsRead(BaseModel):
"""Model for returning settings."""
api_keys: List[str]
admin_api_keys: List[str]
proxy_url: str | None
claude_ai_url: HttpUrl
claude_api_baseurl: HttpUrl
custom_prompt: str | None
use_real_roles: bool
human_name: str
assistant_name: str
padtxt_length: int
allow_external_images: bool
preserve_chats: bool
oauth_client_id: str
oauth_authorize_url: str
oauth_token_url: str
oauth_redirect_uri: str
class SettingsUpdate(BaseModel):
"""Model for updating settings."""
api_keys: List[str] | None = None
admin_api_keys: List[str] | None = None
proxy_url: str | None = None
claude_ai_url: HttpUrl | None = None
claude_api_baseurl: HttpUrl | None = None
custom_prompt: str | None = None
use_real_roles: bool | None = None
human_name: str | None = None
assistant_name: str | None = None
padtxt_length: int | None = None
allow_external_images: bool | None = None
preserve_chats: bool | None = None
oauth_client_id: str | None = None
oauth_authorize_url: str | None = None
oauth_token_url: str | None = None
oauth_redirect_uri: str | None = None
router = APIRouter()
@router.get("", response_model=SettingsRead)
async def get_settings(_: AdminAuthDep) -> Settings:
"""Get current settings."""
return settings
@router.put("", response_model=SettingsUpdate)
async def update_settings(_: AdminAuthDep, updates: SettingsUpdate) -> Settings:
"""Update settings and save to config.json."""
update_dict = updates.model_dump(exclude_unset=True)
if not settings.no_filesystem_mode:
config_path = settings.data_folder / "config.json"
settings.data_folder.mkdir(parents=True, exist_ok=True)
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
config_data = SettingsUpdate.model_validate_json(f.read())
except (json.JSONDecodeError, IOError):
config_data = SettingsUpdate()
else:
config_data = SettingsUpdate()
config_data = config_data.model_copy(update=update_dict)
try:
with open(config_path, "w", encoding="utf-8") as f:
f.write(config_data.model_dump_json(exclude_unset=True))
except IOError as e:
raise HTTPException(
status_code=500, detail=f"Failed to save config: {str(e)}"
)
for key, value in update_dict.items():
if hasattr(settings, key):
setattr(settings, key, value)
return settings
================================================
FILE: app/api/routes/statistics.py
================================================
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel
from app.dependencies.auth import AdminAuthDep
from app.services.account import account_manager
class AccountStats(BaseModel):
total_accounts: int
valid_accounts: int
rate_limited_accounts: int
invalid_accounts: int
active_sessions: int
class StatisticsResponse(BaseModel):
status: Literal["healthy", "degraded"]
accounts: AccountStats
router = APIRouter()
@router.get("", response_model=StatisticsResponse)
async def get_statistics(_: AdminAuthDep):
"""Get system statistics. Requires admin authentication."""
stats = await account_manager.get_status()
return {
"status": "healthy" if stats["valid_accounts"] > 0 else "degraded",
"accounts": stats,
}
================================================
FILE: app/core/__init__.py
================================================
================================================
FILE: app/core/account.py
================================================
from typing import List, Optional
from enum import Enum
from datetime import datetime
from dataclasses import dataclass
from app.core.exceptions import (
ClaudeAuthenticationError,
ClaudeRateLimitedError,
OAuthAuthenticationNotAllowedError,
OrganizationDisabledError,
)
class AccountStatus(str, Enum):
VALID = "valid"
INVALID = "invalid"
RATE_LIMITED = "rate_limited"
class AuthType(str, Enum):
COOKIE_ONLY = "cookie_only"
OAUTH_ONLY = "oauth_only"
BOTH = "both"
@dataclass
class OAuthToken:
"""Encapsulates OAuth credentials for an account."""
access_token: str
refresh_token: str
expires_at: float # Unix timestamp
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"access_token": self.access_token,
"refresh_token": self.refresh_token,
"expires_at": self.expires_at,
}
@classmethod
def from_dict(cls, data: dict) -> "OAuthToken":
"""Create from dictionary."""
return cls(
access_token=data["access_token"],
refresh_token=data["refresh_token"],
expires_at=data["expires_at"],
)
class Account:
"""Represents a Claude.ai account with cookie and/or OAuth authentication."""
def __init__(
self,
organization_uuid: str,
capabilities: Optional[List[str]] = None,
cookie_value: Optional[str] = None,
oauth_token: Optional[OAuthToken] = None,
auth_type: AuthType = AuthType.COOKIE_ONLY,
):
self.organization_uuid = organization_uuid
self.capabilities = capabilities
self.cookie_value = cookie_value
self.status = AccountStatus.VALID
self.auth_type = auth_type
self.last_used = datetime.now()
self.resets_at: Optional[datetime] = None
self.oauth_token: Optional[OAuthToken] = oauth_token
def __enter__(self) -> "Account":
"""Enter the context manager."""
self.last_used = datetime.now()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit the context manager and handle CookieRateLimitedError."""
if exc_type is ClaudeRateLimitedError and isinstance(
exc_val, ClaudeRateLimitedError
):
self.status = AccountStatus.RATE_LIMITED
self.resets_at = exc_val.resets_at
self.save()
if exc_type is ClaudeAuthenticationError and isinstance(
exc_val, ClaudeAuthenticationError
):
self.status = AccountStatus.INVALID
self.save()
if exc_type is OrganizationDisabledError and isinstance(
exc_val, OrganizationDisabledError
):
self.status = AccountStatus.INVALID
self.save()
if exc_type is OAuthAuthenticationNotAllowedError and isinstance(
exc_val, OAuthAuthenticationNotAllowedError
):
if self.auth_type == AuthType.BOTH:
self.auth_type = AuthType.COOKIE_ONLY
else:
self.status = AccountStatus.INVALID
self.save()
return False
def save(self) -> None:
from app.services.account import account_manager
account_manager.save_accounts()
def to_dict(self) -> dict:
"""Convert Account to dictionary for JSON serialization."""
return {
"organization_uuid": self.organization_uuid,
"capabilities": self.capabilities,
"cookie_value": self.cookie_value,
"status": self.status.value,
"auth_type": self.auth_type.value,
"last_used": self.last_used.isoformat(),
"resets_at": self.resets_at.isoformat() if self.resets_at else None,
"oauth_token": self.oauth_token.to_dict() if self.oauth_token else None,
}
@classmethod
def from_dict(cls, data: dict) -> "Account":
"""Create Account from dictionary."""
account = cls(
organization_uuid=data["organization_uuid"],
capabilities=data.get("capabilities"),
cookie_value=data.get("cookie_value"),
auth_type=AuthType(data["auth_type"]),
)
account.status = AccountStatus(data["status"])
account.last_used = datetime.fromisoformat(data["last_used"])
account.resets_at = (
datetime.fromisoformat(data["resets_at"]) if data["resets_at"] else None
)
if "oauth_token" in data and data["oauth_token"]:
account.oauth_token = OAuthToken.from_dict(data["oauth_token"])
return account
@property
def is_pro(self) -> bool:
"""Check if account has pro capabilities."""
if not self.capabilities:
return False
pro_keywords = ["pro", "enterprise", "raven", "max"]
return any(
keyword in cap.lower()
for cap in self.capabilities
for keyword in pro_keywords
)
@property
def is_max(self) -> bool:
"""Check if account has max capabilities."""
if not self.capabilities:
return False
return any("max" in cap.lower() for cap in self.capabilities)
def __repr__(self) -> str:
"""String representation of the Account."""
return f""
================================================
FILE: app/core/claude_session.py
================================================
from typing import Dict, Any, AsyncIterator, Optional
from datetime import datetime
from app.core.http_client import Response
from loguru import logger
from app.core.config import settings
from app.core.external.claude_client import ClaudeWebClient
from app.services.account import account_manager
class ClaudeWebSession:
def __init__(self, session_id: str):
self.session_id = session_id
self.last_activity = datetime.now()
self.conv_uuid: Optional[str] = None
self.paprika_mode: Optional[str] = None
self.sse_stream: Optional[AsyncIterator[str]] = None
async def initialize(self):
"""Initialize the session."""
self.account = await account_manager.get_account_for_session(self.session_id)
self.client = ClaudeWebClient(self.account)
await self.client.initialize()
async def stream(self, response: Response) -> AsyncIterator[str]:
"""Get the SSE stream."""
buffer = b""
async for chunk in response.aiter_bytes():
self.update_activity()
buffer += chunk
lines = buffer.split(b"\n")
buffer = lines[-1]
for line in lines[:-1]:
yield line.decode("utf-8") + "\n"
if buffer:
yield buffer.decode("utf-8")
logger.debug(f"Stream completed for session {self.session_id}")
from app.services.session import session_manager
await session_manager.remove_session(self.session_id)
async def cleanup(self):
"""Cleanup session resources."""
logger.debug(f"Cleaning up session {self.session_id}")
# Delete conversation if exists
if self.conv_uuid and not settings.preserve_chats:
await self.client.delete_conversation(self.conv_uuid)
await account_manager.release_session(self.session_id)
await self.client.cleanup()
async def _ensure_conversation_initialized(self) -> None:
"""Ensure conversation is initialized. Create if not exists."""
if not self.conv_uuid:
conv_uuid, paprika_mode = await self.client.create_conversation()
self.conv_uuid = conv_uuid
self.paprika_mode = paprika_mode
def update_activity(self):
"""Update last activity timestamp."""
self.last_activity = datetime.now()
async def send_message(self, payload: Dict[str, Any]) -> AsyncIterator[str]:
"""Process a completion request through the pipeline."""
self.update_activity()
await self._ensure_conversation_initialized()
response = await self.client.send_message(
payload,
conv_uuid=self.conv_uuid,
)
self.sse_stream = self.stream(response)
logger.debug(f"Sent message for session {self.session_id}")
return self.sse_stream
async def upload_file(
self, file_data: bytes, filename: str, content_type: str
) -> str:
"""Upload a file and return file UUID."""
return await self.client.upload_file(file_data, filename, content_type)
async def send_tool_result(self, payload: Dict[str, Any]) -> None:
"""Send tool result to Claude.ai."""
if not self.conv_uuid:
raise ValueError(
"Session must have an active conversation to send tool results"
)
await self.client.send_tool_result(payload, self.conv_uuid)
async def set_paprika_mode(self, mode: Optional[str]) -> None:
"""Set the conversation mode."""
await self._ensure_conversation_initialized()
if self.paprika_mode == mode:
return
await self.client.set_paprika_mode(self.conv_uuid, mode)
self.paprika_mode = mode
================================================
FILE: app/core/config.py
================================================
import os
import json
from pathlib import Path
from typing import Optional, List, Dict, Any
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, HttpUrl, field_validator
from dotenv import load_dotenv
class Settings(BaseSettings):
"""Application settings with environment variable and JSON config support."""
model_config = SettingsConfigDict(
env_file=".env",
env_ignore_empty=True,
extra="ignore",
)
@classmethod
def settings_customise_sources(
cls,
settings_cls,
init_settings,
env_settings,
dotenv_settings,
file_secret_settings,
):
"""Customize settings sources to add JSON config support.
Priority order (highest to lowest):
1. JSON config file
2. Environment variables
3. .env file
4. Default values
"""
return (
init_settings,
cls._json_config_settings,
env_settings,
dotenv_settings,
file_secret_settings,
)
@classmethod
def _json_config_settings(cls) -> Dict[str, Any]:
"""Load settings from JSON config file in data_folder."""
# Check if NO_FILESYSTEM_MODE is enabled
if os.environ.get("NO_FILESYSTEM_MODE", "").lower() in ("true", "1", "yes"):
return {}
# Load .env file to ensure environment variables are available
load_dotenv()
# First get data_folder from env or default
data_folder = os.environ.get(
"DATA_FOLDER", str(Path.home() / ".clove" / "data")
)
config_path = os.path.join(data_folder, "config.json")
if os.path.exists(config_path):
try:
with open(config_path, "r", encoding="utf-8") as f:
config_data = json.load(f)
return config_data
except (json.JSONDecodeError, IOError):
# If there's an error reading the JSON, just return empty dict
return {}
return {}
# Server settings
host: str = Field(default="0.0.0.0", env="HOST")
port: int = Field(default=5201, env="PORT")
# Application configuration
data_folder: Path = Field(
default=Path.home() / ".clove" / "data",
env="DATA_FOLDER",
description="Folder path for storing persistent data (accounts, etc.)",
)
locales_folder: Path = Field(
default=Path(__file__).parent.parent / "locales",
env="LOCALES_FOLDER",
description="Folder path for storing translation files",
)
static_folder: Path = Field(
default=Path(__file__).parent.parent / "static",
env="STATIC_FOLDER",
description="Folder path for storing static files",
)
default_language: str = Field(
default="en",
env="DEFAULT_LANGUAGE",
description="Default language code for translations",
)
retry_attempts: int = Field(
default=3,
env="RETRY_ATTEMPTS",
description="Number of retry attempts for failed requests",
)
retry_interval: int = Field(
default=1,
env="RETRY_INTERVAL",
description="Interval between retry attempts in seconds",
)
no_filesystem_mode: bool = Field(
default=False,
env="NO_FILESYSTEM_MODE",
description="When True, disables all filesystem operations (accounts/settings stored in memory only)",
)
# Proxy settings
proxy_url: Optional[str] = Field(default=None, env="PROXY_URL")
# API Keys
api_keys: List[str] | str = Field(
default_factory=list,
env="API_KEYS",
description="Comma-separated list of API keys",
)
admin_api_keys: List[str] | str = Field(
default_factory=list,
env="ADMIN_API_KEYS",
description="Comma-separated list of admin API keys",
)
# Claude URLs
claude_ai_url: HttpUrl = Field(default="https://claude.ai", env="CLAUDE_AI_URL")
claude_api_baseurl: HttpUrl = Field(
default="https://api.anthropic.com", env="CLAUDE_API_BASEURL"
)
# Cookies
cookies: List[str] | str = Field(
default_factory=list,
env="COOKIES",
description="Comma-separated list of Claude.ai cookies",
)
# Content processing
custom_prompt: Optional[str] = Field(default=None, env="CUSTOM_PROMPT")
use_real_roles: bool = Field(default=True, env="USE_REAL_ROLES")
human_name: str = Field(default="Human", env="CUSTOM_HUMAN_NAME")
assistant_name: str = Field(default="Assistant", env="CUSTOM_ASSISTANT_NAME")
pad_tokens: List[str] | str = Field(default_factory=list, env="PAD_TOKENS")
padtxt_length: int = Field(default=0, env="PADTXT_LENGTH")
allow_external_images: bool = Field(
default=False,
env="ALLOW_EXTERNAL_IMAGES",
description="Allow downloading images from external URLs",
)
# Request settings
request_timeout: int = Field(default=60, env="REQUEST_TIMEOUT")
request_retries: int = Field(default=3, env="REQUEST_RETRIES")
request_retry_interval: int = Field(default=1, env="REQUEST_RETRY_INTERVAL")
# Feature flags
preserve_chats: bool = Field(default=False, env="PRESERVE_CHATS")
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
log_to_file: bool = Field(
default=False, env="LOG_TO_FILE", description="Enable logging to file"
)
log_file_path: str = Field(
default="logs/app.log", env="LOG_FILE_PATH", description="Log file path"
)
log_file_rotation: str = Field(
default="10 MB",
env="LOG_FILE_ROTATION",
description="Log file rotation (e.g., '10 MB', '1 day', '1 week')",
)
log_file_retention: str = Field(
default="7 days",
env="LOG_FILE_RETENTION",
description="Log file retention (e.g., '7 days', '1 month')",
)
log_file_compression: str = Field(
default="zip",
env="LOG_FILE_COMPRESSION",
description="Log file compression format",
)
# Session management settings
session_timeout: int = Field(
default=300,
env="SESSION_TIMEOUT",
description="Session idle timeout in seconds",
)
session_cleanup_interval: int = Field(
default=30,
env="SESSION_CLEANUP_INTERVAL",
description="Interval for cleaning up expired sessions in seconds",
)
max_sessions_per_cookie: int = Field(
default=3,
env="MAX_SESSIONS_PER_COOKIE",
description="Maximum number of concurrent sessions per cookie",
)
# Account management settings
account_task_interval: int = Field(
default=60,
env="ACCOUNT_TASK_INTERVAL",
description="Interval for account management task in seconds",
)
# Tool call settings
tool_call_timeout: int = Field(
default=300,
env="TOOL_CALL_TIMEOUT",
description="Timeout for pending tool calls in seconds",
)
tool_call_cleanup_interval: int = Field(
default=60,
env="TOOL_CALL_CLEANUP_INTERVAL",
description="Interval for cleaning up expired tool calls in seconds",
)
# Cache settings
cache_timeout: int = Field(
default=300,
env="CACHE_TIMEOUT",
description="Timeout for cache checkpoints in seconds (default: 5 minutes)",
)
cache_cleanup_interval: int = Field(
default=60,
env="CACHE_CLEANUP_INTERVAL",
description="Interval for cleaning up expired cache checkpoints in seconds",
)
# Claude OAuth settings
oauth_client_id: str = Field(
default="9d1c250a-e61b-44d9-88ed-5944d1962f5e",
env="OAUTH_CLIENT_ID",
description="OAuth client ID for Claude authentication",
)
oauth_authorize_url: str = Field(
default="https://claude.ai/v1/oauth/{organization_uuid}/authorize",
env="OAUTH_AUTHORIZE_URL",
description="OAuth authorization endpoint URL template",
)
oauth_token_url: str = Field(
default="https://console.anthropic.com/v1/oauth/token",
env="OAUTH_TOKEN_URL",
description="OAuth token exchange endpoint URL",
)
oauth_redirect_uri: str = Field(
default="https://console.anthropic.com/oauth/code/callback",
env="OAUTH_REDIRECT_URI",
description="OAuth redirect URI for authorization flow",
)
# Claude API Specific
max_models: List[str] | str = Field(
default=[],
env="MAX_MODELS",
description="Comma-separated list of models that require max plan accounts",
)
@field_validator(
"api_keys", "admin_api_keys", "cookies", "max_models", "pad_tokens"
)
def parse_comma_separated(cls, v: str | List[str]) -> List[str]:
"""Parse comma-separated string."""
if isinstance(v, str):
return [key.strip() for key in v.split(",") if key.strip()]
return v
settings = Settings()
================================================
FILE: app/core/error_handler.py
================================================
from typing import Dict, Any
from fastapi import Request
from fastapi.responses import JSONResponse
from loguru import logger
from app.services.i18n import i18n_service
from app.core.exceptions import AppError
class ErrorHandler:
"""
Centralized error handler for the application. Handles AppException.
"""
@staticmethod
def get_language_from_request(request: Request) -> str:
"""Extract language preference from request headers."""
accept_language = request.headers.get("accept-language")
return i18n_service.parse_accept_language(accept_language)
@staticmethod
def format_error_response(
error_code: int, message: str, context: Dict[str, Any] = None
) -> Dict[str, Any]:
"""
Format error response in standardized format.
Args:
error_code: 6-digit error code
message: Localized error message
context: Additional context information
Returns:
Formatted error response
"""
response = {"detail": {"code": error_code, "message": message}}
# Add context if provided and not empty
if context:
response["detail"]["context"] = context
return response
@staticmethod
async def handle_app_exception(request: Request, exc: AppError) -> JSONResponse:
"""
Handle AppException instances.
Args:
request: The FastAPI request object
exc: The AppException instance
Returns:
JSONResponse with localized error message
"""
language = ErrorHandler.get_language_from_request(request)
# Get localized message
message = i18n_service.get_message(
message_key=exc.message_key, language=language, context=exc.context
)
# Format response
response_data = ErrorHandler.format_error_response(
error_code=exc.error_code,
message=message,
context=exc.context if exc.context else None,
)
# Log the error
logger.warning(
f"AppException: {exc.__class__.__name__} - "
f"Code: {exc.error_code}, Message: {message}, "
f"Context: {exc.context}"
)
return JSONResponse(status_code=exc.status_code, content=response_data)
# Exception handler functions for FastAPI
async def app_exception_handler(request: Request, exc: AppError) -> JSONResponse:
"""FastAPI exception handler for AppException."""
return await ErrorHandler.handle_app_exception(request, exc)
================================================
FILE: app/core/exceptions.py
================================================
from datetime import datetime
from typing import Optional, Any, Dict
class AppError(Exception):
"""
Base class for application-specific exceptions.
"""
def __init__(
self,
error_code: int,
message_key: str,
status_code: int,
context: Optional[Dict[str, Any]] = None,
retryable: bool = False,
):
self.error_code = error_code
self.message_key = message_key
self.status_code = status_code
self.context = context if context is not None else {}
self.retryable = retryable
super().__init__(
f"Error Code: {error_code}, Message Key: {message_key}, Context: {self.context}"
)
def __str__(self):
return f"{self.__class__.__name__}(error_code={self.error_code}, message_key='{self.message_key}', status_code={self.status_code}, context={self.context})"
class InternalServerError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=500000,
message_key="global.internalServerError",
status_code=500,
context=context,
)
class NoAPIKeyProvidedError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=401010,
message_key="global.noAPIKeyProvided",
status_code=401,
context=context,
)
class InvalidAPIKeyError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=401011,
message_key="global.invalidAPIKey",
status_code=401,
context=context,
)
class NoAccountsAvailableError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=503100,
message_key="accountManager.noAccountsAvailable",
status_code=503,
context=context,
retryable=True,
)
class ClaudeRateLimitedError(AppError):
resets_at: datetime
def __init__(self, resets_at: datetime, context: Optional[Dict[str, Any]] = None):
self.resets_at = resets_at
_context = context.copy() if context else {}
_context["resets_at"] = resets_at.strftime("%Y-%m-%dT%H:%M:%SZ")
super().__init__(
error_code=429120,
message_key="claudeClient.claudeRateLimited",
status_code=429,
context=_context,
retryable=True,
)
class CloudflareBlockedError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=503121,
message_key="claudeClient.cloudflareBlocked",
status_code=503,
context=context,
)
class OrganizationDisabledError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=400122,
message_key="claudeClient.organizationDisabled",
status_code=400,
context=context,
retryable=True,
)
class InvalidModelNameError(AppError):
def __init__(self, model_name: str, context: Optional[Dict[str, Any]] = None):
_context = context.copy() if context else {}
_context["model_name"] = model_name
super().__init__(
error_code=400123,
message_key="claudeClient.invalidModelName",
status_code=400,
context=_context,
)
class ClaudeAuthenticationError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=400124,
message_key="claudeClient.authenticationError",
status_code=400,
context=context,
)
class ClaudeHttpError(AppError):
def __init__(
self,
url,
status_code: int,
error_type: str,
error_message: Any,
context: Optional[Dict[str, Any]] = None,
):
_context = context.copy() if context else {}
_context.update({
"url": url,
"status_code": status_code,
"error_type": error_type,
"error_message": error_message,
})
super().__init__(
error_code=503130,
message_key="claudeClient.httpError",
status_code=status_code,
context=_context,
retryable=True,
)
class NoValidMessagesError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=400140,
message_key="messageProcessor.noValidMessages",
status_code=400,
context=context,
)
class ExternalImageDownloadError(AppError):
def __init__(self, url: str, context: Optional[Dict[str, Any]] = None):
_context = context.copy() if context else {}
_context.update({"url": url})
super().__init__(
error_code=503141,
message_key="messageProcessor.externalImageDownloadError",
status_code=503,
context=_context,
)
class ExternalImageNotAllowedError(AppError):
def __init__(self, url: str, context: Optional[Dict[str, Any]] = None):
_context = context.copy() if context else {}
_context.update({"url": url})
super().__init__(
error_code=400142,
message_key="messageProcessor.externalImageNotAllowed",
status_code=400,
context=_context,
)
class NoResponseError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=503160,
message_key="pipeline.noResponse",
status_code=503,
context=context,
)
class OAuthExchangeError(AppError):
def __init__(self, reason: str, context: Optional[Dict[str, Any]] = None):
_context = context.copy() if context else {}
_context["reason"] = reason or "Unknown"
super().__init__(
error_code=400180,
message_key="oauthService.oauthExchangeError",
status_code=400,
context=_context,
)
class OrganizationInfoError(AppError):
def __init__(self, reason: str, context: Optional[Dict[str, Any]] = None):
_context = context.copy() if context else {}
_context["reason"] = reason or "Unknown"
super().__init__(
error_code=503181,
message_key="oauthService.organizationInfoError",
status_code=503,
context=_context,
)
class CookieAuthorizationError(AppError):
def __init__(self, reason: str, context: Optional[Dict[str, Any]] = None):
_context = context.copy() if context else {}
_context["reason"] = reason or "Unknown"
super().__init__(
error_code=400182,
message_key="oauthService.cookieAuthorizationError",
status_code=400,
context=_context,
)
class OAuthAuthenticationNotAllowedError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=400183,
message_key="oauthService.oauthAuthenticationNotAllowed",
status_code=400,
context=context,
)
class ClaudeStreamingError(AppError):
def __init__(
self,
error_type: str,
error_message: str,
context: Optional[Dict[str, Any]] = None,
):
_context = context.copy() if context else {}
_context.update({
"error_type": error_type,
"error_message": error_message,
})
super().__init__(
error_code=503500,
message_key="processors.nonStreamingResponseProcessor.streamingError",
status_code=503,
context=_context,
retryable=True,
)
class NoMessageError(AppError):
def __init__(self, context: Optional[Dict[str, Any]] = None):
super().__init__(
error_code=503501,
message_key="processors.nonStreamingResponseProcessor.noMessage",
status_code=503,
context=context,
retryable=True,
)
================================================
FILE: app/core/external/claude_client.py
================================================
import json
from loguru import logger
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from urllib.parse import urljoin
from uuid import uuid4
from app.core.http_client import (
create_session,
Response,
AsyncSession,
)
from app.core.config import settings
from app.core.exceptions import (
ClaudeAuthenticationError,
ClaudeRateLimitedError,
CloudflareBlockedError,
OrganizationDisabledError,
ClaudeHttpError,
)
from app.models.internal import UploadResponse
from app.core.account import Account
class ClaudeWebClient:
"""Client for interacting with Claude.ai."""
def __init__(self, account: Account):
self.account = account
self.session: Optional[AsyncSession] = None
self.endpoint = settings.claude_ai_url.encoded_string().rstrip("/")
async def initialize(self):
"""Initialize the client session."""
self.session = create_session(
timeout=settings.request_timeout,
impersonate="chrome",
proxy=settings.proxy_url,
follow_redirects=False,
)
async def cleanup(self):
"""Clean up resources."""
if self.session:
await self.session.close()
def _build_headers(
self, cookie: str, conv_uuid: Optional[str] = None
) -> Dict[str, str]:
"""Build request headers."""
headers = {
"Accept": "text/event-stream",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
"Cookie": cookie,
"Origin": self.endpoint,
"Referer": f"{self.endpoint}/new",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
if conv_uuid:
headers["Referer"] = f"{self.endpoint}/chat/{conv_uuid}"
return headers
async def _request(
self,
method: str,
url: str,
conv_uuid: Optional[str] = None,
stream=None,
**kwargs,
) -> Response:
"""Make HTTP request with error handling."""
if not self.session:
await self.initialize()
with self.account as account:
cookie_value = account.cookie_value
headers = self._build_headers(cookie_value, conv_uuid)
kwargs["headers"] = {**headers, **kwargs.get("headers", {})}
response: Response = await self.session.request(
method=method, url=url, stream=stream, **kwargs
)
if response.status_code < 300:
return response
if response.status_code == 302:
raise CloudflareBlockedError()
try:
error_data = await response.json()
error_body = error_data.get("error", {})
error_message = error_body.get("message", "Unknown error")
error_type = error_body.get("type", "unknown")
except Exception:
error_message = f"HTTP {response.status_code} error with empty response"
error_type = "empty_response"
if (
response.status_code == 400
and error_message == "This organization has been disabled."
):
raise OrganizationDisabledError()
if response.status_code == 403 and error_message == "Invalid authorization":
raise ClaudeAuthenticationError()
if response.status_code == 429:
try:
error_message_data = json.loads(error_message)
resets_at = error_message_data.get("resetsAt")
if resets_at and isinstance(resets_at, int):
reset_time = datetime.fromtimestamp(resets_at, tz=timezone.utc)
logger.error(f"Rate limit exceeded, resets at: {reset_time}")
raise ClaudeRateLimitedError(resets_at=reset_time)
except json.JSONDecodeError:
pass
raise ClaudeHttpError(
url=url,
status_code=response.status_code,
error_type=error_type,
error_message=error_message,
)
async def create_conversation(self) -> str:
"""Create a new conversation."""
url = urljoin(
self.endpoint,
f"/api/organizations/{self.account.organization_uuid}/chat_conversations",
)
uuid = uuid4()
payload = {
"name": "Hello World!",
"uuid": str(uuid),
}
response = await self._request("POST", url, json=payload)
data = await response.json()
conv_uuid = data.get("uuid")
paprika_mode = data.get("settings", {}).get("paprika_mode")
logger.info(f"Created conversation: {conv_uuid}")
return conv_uuid, paprika_mode
async def set_paprika_mode(self, conv_uuid: str, mode: Optional[str]) -> None:
"""Set the conversation mode."""
url = urljoin(
self.endpoint,
f"/api/organizations/{self.account.organization_uuid}/chat_conversations/{conv_uuid}",
)
payload = {"settings": {"paprika_mode": mode}}
await self._request("PUT", url, json=payload)
logger.debug(f"Set conversation {conv_uuid} mode: {mode}")
async def upload_file(
self, file_data: bytes, filename: str, content_type: str
) -> str:
"""Upload a file and return file UUID."""
url = urljoin(self.endpoint, f"/api/{self.account.organization_uuid}/upload")
files = {"file": (filename, file_data, content_type)}
response = await self._request("POST", url, files=files)
data = UploadResponse.model_validate(await response.json())
return data.file_uuid
async def send_message(self, payload: Dict[str, Any], conv_uuid: str) -> Response:
"""Send a message and return the response."""
url = urljoin(
self.endpoint,
f"/api/organizations/{self.account.organization_uuid}/chat_conversations/{conv_uuid}/completion",
)
headers = {
"Accept": "text/event-stream",
}
response = await self._request(
"POST", url, conv_uuid=conv_uuid, json=payload, headers=headers, stream=True
)
return response
async def send_tool_result(self, payload: Dict[str, Any], conv_uuid: str):
"""Send tool result to Claude.ai."""
url = urljoin(
self.endpoint,
f"/api/organizations/{self.account.organization_uuid}/chat_conversations/{conv_uuid}/tool_result",
)
await self._request("POST", url, conv_uuid=conv_uuid, json=payload)
async def delete_conversation(self, conv_uuid: str) -> None:
"""Delete a conversation."""
if not conv_uuid:
return
url = urljoin(
self.endpoint,
f"/api/organizations/{self.account.organization_uuid}/chat_conversations/{conv_uuid}",
)
try:
await self._request("DELETE", url, conv_uuid=conv_uuid)
logger.info(f"Deleted conversation: {conv_uuid}")
except Exception as e:
logger.warning(f"Failed to delete conversation: {e}")
================================================
FILE: app/core/http_client.py
================================================
"""HTTP client abstraction layer that supports both curl_cffi and httpx."""
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, Tuple, AsyncIterator
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_fixed,
)
from loguru import logger
import json
from app.core.config import settings
from app.utils.retry import log_before_sleep
try:
import rnet
from rnet import Client as RnetClient, Method as RnetMethod
from rnet.exceptions import RequestError as RnetRequestError
RNET_AVAILABLE = True
except ImportError:
RNET_AVAILABLE = False
try:
from curl_cffi.requests import (
AsyncSession as CurlAsyncSession,
Response as CurlResponse,
)
from curl_cffi.requests.exceptions import RequestException as CurlRequestException
import curl_cffi
CURL_CFFI_AVAILABLE = True
except ImportError:
CURL_CFFI_AVAILABLE = False
# Always try to import httpx as fallback
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
if not RNET_AVAILABLE and not CURL_CFFI_AVAILABLE and not HTTPX_AVAILABLE:
raise ImportError(
"Neither rnet, curl_cffi nor httpx is installed. Please install at least one of them."
)
class Response(ABC):
"""Abstract response class."""
@property
@abstractmethod
def status_code(self) -> int:
"""Get response status code."""
pass
@abstractmethod
async def json(self) -> Any:
"""Parse response as JSON."""
pass
@property
@abstractmethod
def headers(self) -> Dict[str, str]:
"""Get response headers."""
pass
@abstractmethod
def aiter_bytes(self, chunk_size: Optional[int] = None) -> AsyncIterator[bytes]:
"""Iterate over response bytes."""
pass
class CurlResponseWrapper(Response):
"""curl_cffi response wrapper."""
def __init__(self, response: "CurlResponse", stream: bool = False):
self._response = response
self._stream = stream
@property
def status_code(self) -> int:
return self._response.status_code
async def json(self) -> Any:
if self._stream:
content = ""
async for chunk in self._response.aiter_content():
content += chunk.decode("utf-8")
return json.loads(content)
else:
return self._response.json()
@property
def headers(self) -> Dict[str, str]:
return self._response.headers
async def aiter_bytes(
self, chunk_size: Optional[int] = None
) -> AsyncIterator[bytes]:
async for chunk in self._response.aiter_content(chunk_size):
yield chunk
await self._response.aclose()
class HttpxResponse(Response):
"""httpx response wrapper."""
def __init__(self, response: httpx.Response):
self._response = response
@property
def status_code(self) -> int:
return self._response.status_code
async def json(self) -> Any:
await self._response.aread()
return self._response.json()
@property
def headers(self) -> Dict[str, str]:
return self._response.headers
async def aiter_bytes(
self, chunk_size: Optional[int] = None
) -> AsyncIterator[bytes]:
async for chunk in self._response.aiter_bytes(chunk_size):
yield chunk
await self._response.aclose()
if RNET_AVAILABLE:
class RnetResponse(Response):
"""rnet response wrapper."""
def __init__(self, response: "rnet.Response"):
self._response = response
@property
def status_code(self) -> int:
return self._response.status.as_int()
async def json(self) -> Any:
return await self._response.json()
@property
def headers(self) -> Dict[str, str]:
headers_dict = {}
for key, value in self._response.headers:
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
value_str = value.decode("utf-8") if isinstance(value, bytes) else value
headers_dict[key_str] = value_str
return headers_dict
async def aiter_bytes(
self, chunk_size: Optional[int] = None
) -> AsyncIterator[bytes]:
async with self._response.stream() as streamer:
async for chunk in streamer:
yield chunk
await self._response.close()
class AsyncSession(ABC):
"""Abstract async session class."""
@abstractmethod
async def request(
self,
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
json: Optional[Any] = None,
data: Optional[Any] = None,
stream: bool = False,
**kwargs,
) -> Response:
"""Make an HTTP request."""
pass
@abstractmethod
async def close(self):
"""Close the session."""
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
if CURL_CFFI_AVAILABLE:
class CurlAsyncSessionWrapper(AsyncSession):
"""curl_cffi async session wrapper."""
def __init__(
self,
timeout: int = settings.request_timeout,
impersonate: str = "chrome",
proxy: Optional[str] = settings.proxy_url,
follow_redirects: bool = True,
):
self._session = CurlAsyncSession(
timeout=timeout,
impersonate=impersonate,
proxy=proxy,
allow_redirects=follow_redirects,
)
def process_files(self, files: dict) -> curl_cffi.CurlMime:
# Create multipart form
multipart = curl_cffi.CurlMime()
# Handle different file formats
if isinstance(files, dict):
for field_name, file_info in files.items():
if isinstance(file_info, tuple):
# Format: {"field": (filename, data, content_type)}
if len(file_info) >= 3:
filename, file_data, content_type = file_info[:3]
elif len(file_info) == 2:
filename, file_data = file_info
content_type = "application/octet-stream"
else:
raise ValueError(
f"Invalid file tuple format for field {field_name}"
)
multipart.addpart(
name=field_name,
content_type=content_type,
filename=filename,
data=file_data,
)
else:
# Simple format: {"field": data}
multipart.addpart(
name=field_name,
data=file_info,
)
return multipart
@retry(
stop=stop_after_attempt(settings.request_retries),
wait=wait_fixed(settings.request_retry_interval),
retry=retry_if_exception_type(CurlRequestException),
before_sleep=log_before_sleep,
reraise=True,
)
async def request(
self,
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
json: Optional[Any] = None,
data: Optional[Any] = None,
stream: bool = False,
**kwargs,
) -> Response:
logger.debug(f"Making {method} request to {url}")
# Handle file uploads - convert files parameter to multipart
files = kwargs.pop("files", None)
multipart = None
if files:
multipart = self.process_files(files)
kwargs["multipart"] = multipart
try:
response = await self._session.request(
method=method,
url=url,
headers=headers,
json=json,
data=data,
stream=stream,
**kwargs,
)
return CurlResponseWrapper(response, stream=stream)
finally:
if multipart:
multipart.close()
async def close(self):
await self._session.close()
if RNET_AVAILABLE:
class RnetAsyncSession(AsyncSession):
"""rnet async session wrapper."""
def __init__(
self,
timeout: int = settings.request_timeout,
impersonate: str = "chrome",
proxy: Optional[str] = settings.proxy_url,
follow_redirects: bool = True,
):
# Map impersonate string to rnet Emulation enum
emulation_map = {
"chrome": rnet.Emulation.Chrome142,
"firefox": rnet.Emulation.Firefox136,
"safari": rnet.Emulation.Safari18,
"edge": rnet.Emulation.Edge134,
}
# Use Chrome as default if not found in map
rnet_emulation = emulation_map.get(
impersonate.lower(), rnet.Emulation.Chrome142
)
# Create proxy list if proxy is provided
proxies = None
if proxy:
proxies = [rnet.Proxy.all(proxy)]
self._client = RnetClient(
emulation=rnet_emulation,
timeout=timeout,
proxies=proxies,
allow_redirects=follow_redirects,
)
@retry(
stop=stop_after_attempt(settings.request_retries),
wait=wait_fixed(settings.request_retry_interval),
retry=retry_if_exception_type(RnetRequestError),
before_sleep=log_before_sleep,
reraise=True,
)
async def request(
self,
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
json: Optional[Any] = None,
data: Optional[Any] = None,
stream: bool = False,
**kwargs,
) -> Response:
logger.debug(f"Making {method} request to {url}")
# Map method string to rnet Method enum
method_map = {
"GET": RnetMethod.GET,
"POST": RnetMethod.POST,
"PUT": RnetMethod.PUT,
"DELETE": RnetMethod.DELETE,
"PATCH": RnetMethod.PATCH,
"HEAD": RnetMethod.HEAD,
"OPTIONS": RnetMethod.OPTIONS,
"TRACE": RnetMethod.TRACE,
}
rnet_method = method_map.get(method.upper(), RnetMethod.GET)
# Handle file uploads - convert files parameter to multipart
files = kwargs.pop("files", None)
multipart = None
if files:
# Convert files dict to rnet Multipart
parts = []
for field_name, file_info in files.items():
if isinstance(file_info, tuple):
# Format: {"field": (filename, data, content_type)}
if len(file_info) >= 3:
filename, file_data, content_type = file_info[:3]
elif len(file_info) == 2:
filename, file_data = file_info
content_type = "application/octet-stream"
else:
raise ValueError(
f"Invalid file tuple format for field {field_name}"
)
parts.append(
rnet.Part(
name=field_name,
value=file_data,
filename=filename,
mime=content_type,
)
)
else:
# Simple format: {"field": data}
parts.append(rnet.Part(name=field_name, value=file_info))
multipart = rnet.Multipart(*parts)
kwargs["multipart"] = multipart
request_kwargs = {}
if headers:
request_kwargs["headers"] = headers
if json is not None:
request_kwargs["json"] = json
elif data is not None:
# rnet uses 'form' for form data, 'body' for raw data
if isinstance(data, dict) or isinstance(data, list):
request_kwargs["form"] = (
[(k, v) for k, v in data.items()]
if isinstance(data, dict)
else data
)
else:
request_kwargs["body"] = data
request_kwargs.update(kwargs)
response = await self._client.request(
method=rnet_method,
url=url,
**request_kwargs,
)
return RnetResponse(response)
async def close(self):
# rnet Client doesn't have an explicit close method
# The connection pooling is handled internally
pass
if HTTPX_AVAILABLE:
class HttpxAsyncSession(AsyncSession):
"""httpx async session wrapper."""
def __init__(
self,
timeout: int = settings.request_timeout,
impersonate: str = "chrome",
proxy: Optional[str] = settings.proxy_url,
follow_redirects: bool = True,
):
self._client = httpx.AsyncClient(
timeout=timeout,
proxy=proxy,
follow_redirects=follow_redirects,
)
async def stream(
self,
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
json: Optional[Any] = None,
data: Optional[Any] = None,
**kwargs,
) -> Response:
"""
Alternative to `httpx.request()` that streams the response body
instead of loading it into memory at once.
**Parameters**: See `httpx.request`.
See also: [Streaming Responses][0]
[0]: /quickstart#streaming-responses
"""
request = self._client.build_request(
method=method,
url=url,
data=data,
json=json,
headers=headers,
**kwargs,
)
response = await self._client.send(
request=request,
stream=True,
)
return response
@retry(
stop=stop_after_attempt(settings.request_retries),
wait=wait_fixed(settings.request_retry_interval),
retry=retry_if_exception_type(httpx.RequestError),
before_sleep=log_before_sleep,
reraise=True,
)
async def request(
self,
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
json: Optional[Any] = None,
data: Optional[Any] = None,
stream: bool = False,
**kwargs,
) -> Response:
logger.debug(f"Making {method} request to {url}")
if stream:
response = await self.stream(
method=method,
url=url,
headers=headers,
json=json,
data=data,
**kwargs,
)
else:
response = await self._client.request(
method=method,
url=url,
headers=headers,
json=json,
data=data,
**kwargs,
)
return HttpxResponse(response)
async def close(self):
await self._client.aclose()
def create_session(
timeout: int = settings.request_timeout,
impersonate: str = "chrome",
proxy: Optional[str] = settings.proxy_url,
follow_redirects: bool = True,
) -> AsyncSession:
"""Create an async session using the available HTTP client.
Prefers rnet if available, then curl_cffi, falls back to httpx.
"""
if RNET_AVAILABLE:
logger.debug("Using rnet as HTTP client")
return RnetAsyncSession(
timeout=timeout,
impersonate=impersonate,
proxy=proxy,
follow_redirects=follow_redirects,
)
elif CURL_CFFI_AVAILABLE:
logger.debug("Using curl_cffi as HTTP client")
return CurlAsyncSessionWrapper(
timeout=timeout,
impersonate=impersonate,
proxy=proxy,
follow_redirects=follow_redirects,
)
else:
logger.debug("Using httpx as HTTP client (rnet and curl_cffi not available)")
return HttpxAsyncSession(
timeout=timeout,
impersonate=impersonate,
proxy=proxy,
follow_redirects=follow_redirects,
)
def create_plain_session(
timeout: int = settings.request_timeout,
proxy: Optional[str] = settings.proxy_url,
follow_redirects: bool = True,
) -> AsyncSession:
"""Create a plain HTTP session WITHOUT browser fingerprinting/impersonation.
Used for API endpoints (e.g. OAuth token exchange at console.anthropic.com)
that reject requests containing browser-injected headers (User-Agent, Origin,
TLS fingerprints) with 429 errors.
Prefers httpx (zero header injection). Falls back to curl_cffi or rnet
without impersonation if httpx is unavailable.
"""
if HTTPX_AVAILABLE:
logger.debug("Using httpx as plain HTTP client")
return HttpxAsyncSession(
timeout=timeout,
proxy=proxy,
follow_redirects=follow_redirects,
)
elif CURL_CFFI_AVAILABLE:
logger.debug("Using curl_cffi (no impersonation) as plain HTTP client")
return CurlAsyncSessionWrapper(
timeout=timeout,
impersonate=None,
proxy=proxy,
follow_redirects=follow_redirects,
)
else:
logger.debug("Using rnet (no impersonation) as plain HTTP client")
return RnetAsyncSession(
timeout=timeout,
impersonate=None,
proxy=proxy,
follow_redirects=follow_redirects,
)
async def download_image(url: str, timeout: int = 30) -> Tuple[bytes, str]:
"""Download an image from a URL and return content and content type.
Uses the unified session interface that works with both curl_cffi and httpx.
"""
async with create_session(timeout=timeout) as session:
response = await session.request("GET", url)
content_type = response.headers.get("content-type", "image/jpeg")
# Read the response content
content = b""
async for chunk in response.aiter_bytes():
content += chunk
return content, content_type
# Export the appropriate exception class
if RNET_AVAILABLE:
RequestException = RnetRequestError
elif CURL_CFFI_AVAILABLE:
RequestException = CurlRequestException
else:
RequestException = httpx.RequestError
================================================
FILE: app/core/static.py
================================================
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from loguru import logger
from app.core.config import settings
def register_static_routes(app: FastAPI):
"""Register static file routes for the application."""
if settings.static_folder.exists():
app.mount(
"/assets",
StaticFiles(directory=str(settings.static_folder / "assets")),
name="assets",
)
# Serve index.html for SPA routes
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve index.html for all non-API routes (SPA support)."""
index_path = settings.static_folder / "index.html"
if index_path.exists():
return FileResponse(str(index_path))
raise HTTPException(status_code=404, detail="Frontend not built")
else:
logger.warning(
"Static files directory not found. Run 'pnpm build' in the front directory to build the frontend."
)
================================================
FILE: app/dependencies/__init__.py
================================================
================================================
FILE: app/dependencies/auth.py
================================================
from typing import Optional, Annotated
from loguru import logger
from fastapi import Depends, Header
import secrets
from app.core.config import settings
from app.core.exceptions import InvalidAPIKeyError
_temp_admin_api_key: Optional[str] = None
if not settings.admin_api_keys:
_temp_admin_api_key = f"sk-admin-{secrets.token_urlsafe(32)}"
logger.warning(
f"No admin API keys configured. Generated temporary admin API key: {_temp_admin_api_key}"
)
logger.warning(
"This is a temporary key and will not be saved. Please configure admin API keys in settings."
)
async def get_api_key(
x_api_key: Annotated[Optional[str], Header()] = None,
authorization: Annotated[Optional[str], Header()] = None,
) -> str:
# Check X-API-Key header
api_key = x_api_key
# Check Authorization header
if not api_key and authorization:
if authorization.startswith("Bearer "):
api_key = authorization[7:]
if not api_key:
raise InvalidAPIKeyError()
return api_key
APIKeyDep = Annotated[str, Depends(get_api_key)]
async def verify_api_key(
api_key: APIKeyDep,
) -> str:
# Verify against configured keys
valid_keys = settings.api_keys + settings.admin_api_keys + [_temp_admin_api_key]
if not valid_keys:
logger.error("No API keys configured, Please configure at least one API key.")
raise InvalidAPIKeyError()
if api_key not in valid_keys:
raise InvalidAPIKeyError()
return api_key
AuthDep = Annotated[str, Depends(verify_api_key)]
async def verify_admin_api_key(
api_key: APIKeyDep,
) -> str:
# Verify against configured admin keys
valid_keys = settings.admin_api_keys + [_temp_admin_api_key]
if not valid_keys:
logger.error(
"No admin API keys configured, Please configure at least one admin API key."
)
raise InvalidAPIKeyError()
if api_key not in valid_keys:
raise InvalidAPIKeyError()
return api_key
AdminAuthDep = Annotated[str, Depends(verify_admin_api_key)]
================================================
FILE: app/locales/en.json
================================================
{
"global": {
"internalServerError": "An internal server error occurred. Please try again later.",
"noAPIKeyProvided": "No API key provided. Please include an API key in the request.",
"invalidAPIKey": "Invalid API key. Please check your API key and try again."
},
"accountManager": {
"noAccountsAvailable": "No accounts are currently available. Please try again later."
},
"oauthService": {
"oauthExchangeError": "Failed to exchange authorization code for tokens.",
"organizationInfoError": "Failed to get organization Info: {reason}",
"cookieAuthorizationError": "Failed to authorize with cookie: {reason}",
"oauthAuthenticationNotAllowed": "OAuth authentication is not allowed for this organization. Only Pro and Max accounts support OAuth authentication."
},
"claudeClient": {
"claudeRateLimited": "Claude AI rate limit exceeded. Please try again after {resets_at}.",
"cloudflareBlocked": "Request blocked by Cloudflare. Please check your IP address.",
"organizationDisabled": "Your Claude AI account has been disabled.",
"httpError": "HTTP error occurred when calling Claude AI: {error_type} - {error_message} (Status: {status_code})",
"invalidModelName": "Invalid model name provided. Please ensure you have access to model {model_name}.",
"authenticationError": "Authentication error. Please check your Claude Cookie or OAuth credentials, and ensure you have installed the curl dependency and are not in a Termux environment."
},
"messageProcessor": {
"noValidMessages": "No valid messages found in the request.",
"externalImageDownloadError": "Failed to download external image from: {url}",
"externalImageNotAllowed": "External images are not allowed: {url}"
},
"pipeline": {
"noResponse": "No response received from the service. Please try again."
},
"processors": {
"nonStreamingResponseProcessor": {
"streamingError": "Streaming error occurred: {error_type} - {error_message}",
"noMessage": "No message received in the response."
}
}
}
================================================
FILE: app/locales/zh.json
================================================
{
"global": {
"internalServerError": "服务器内部错误。请稍后重试。",
"noAPIKeyProvided": "未提供 API 密钥。请在请求中包含 API 密钥。",
"invalidAPIKey": "无效的 API 密钥。请检查您的 API 密钥并重试。"
},
"accountManager": {
"noAccountsAvailable": "当前没有可用的账户。请稍后重试。"
},
"oauthService": {
"oauthExchangeError": "无法将授权代码兑换为令牌:{reason}",
"organizationInfoError": "无法获取组织信息:{reason}",
"cookieAuthorizationError": "无法使用 Cookie 进行授权:{reason}",
"oauthAuthenticationNotAllowed": "此组织不允许 OAuth 认证。仅有 Pro 和 Max 账户支持 OAuth 认证。"
},
"claudeClient": {
"claudeRateLimited": "Claude API 速率限制已超出。请在 {resets_at} 后重试。",
"cloudflareBlocked": "请求被 Cloudflare 阻止。请检查您的连接。",
"organizationDisabled": "您的 Claude AI 账户已被禁用。",
"httpError": "请求 Claude AI 时发生 HTTP 错误:{error_type} - {error_message}(状态码:{status_code})",
"invalidModelName": "提供的模型名称无效。请确保您有权访问 {model_name} 模型。",
"authenticationError": "身份验证错误。请检查您的 Claude Cookie 或 OAuth 凭证;并确保安装了 curl 依赖且不在 Termux 环境下。"
},
"messageProcessor": {
"noValidMessages": "请求中未找到有效消息。",
"externalImageDownloadError": "无法从以下地址下载外部图片:{url}",
"externalImageNotAllowed": "不允许使用外部图片:{url}"
},
"pipeline": {
"noResponse": "未收到服务响应。请重试。"
},
"processors": {
"nonStreamingResponseProcessor": {
"streamingError": "流式传输中收到错误:{error_type} - {error_message}",
"noMessage": "响应中未收到消息。"
}
}
}
================================================
FILE: app/main.py
================================================
from loguru import logger
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.main import api_router
from app.core.config import settings
from app.core.error_handler import app_exception_handler
from app.core.exceptions import AppError
from app.core.static import register_static_routes
from app.utils.logger import configure_logger
from app.services.account import account_manager
from app.services.session import session_manager
from app.services.tool_call import tool_call_manager
from app.services.cache import cache_service
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
logger.info("Starting Clove...")
configure_logger()
# Load accounts
account_manager.load_accounts()
for cookie in settings.cookies:
await account_manager.add_account(cookie_value=cookie)
# Start tasks
await account_manager.start_task()
await session_manager.start_cleanup_task()
await tool_call_manager.start_cleanup_task()
await cache_service.start_cleanup_task()
yield
logger.info("Shutting down Clove...")
# Save accounts
account_manager.save_accounts()
# Stop tasks
await account_manager.stop_task()
await session_manager.cleanup_all()
await tool_call_manager.cleanup_all()
await cache_service.cleanup_all()
app = FastAPI(
title="Clove",
description="A Claude.ai reverse proxy",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(api_router)
# Static files
register_static_routes(app)
# Exception handlers
app.add_exception_handler(AppError, app_exception_handler)
# Health check
@app.get("/health")
async def health():
"""Health check endpoint."""
stats = await account_manager.get_status()
return {"status": "healthy" if stats["valid_accounts"] > 0 else "degraded"}
def main():
"""Main entry point for the application."""
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
reload=False,
)
if __name__ == "__main__":
main()
================================================
FILE: app/models/__init__.py
================================================
================================================
FILE: app/models/claude.py
================================================
from typing import Optional, List, Union, Literal, Dict, Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
from enum import Enum
class Role(str, Enum):
USER = "user"
ASSISTANT = "assistant"
class ImageType(str, Enum):
JPEG = "image/jpeg"
PNG = "image/png"
GIF = "image/gif"
WEBP = "image/webp"
# Image sources
class Base64ImageSource(BaseModel):
type: Literal["base64"] = "base64"
media_type: ImageType = Field(..., description="MIME type of the image")
data: str = Field(..., description="Base64 encoded image data")
class URLImageSource(BaseModel):
type: Literal["url"] = "url"
url: str = Field(..., description="URL of the image")
class FileImageSource(BaseModel):
type: Literal["file"] = "file"
file_uuid: str = Field(..., description="UUID of the uploaded file")
# Web search result
class WebSearchResult(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["web_search_result"]
title: str
url: str
encrypted_content: str
page_age: Optional[str] = None
# Cache control
class CacheControl(BaseModel):
type: Literal["ephemeral"]
# Content types
class TextContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["text"]
text: str
cache_control: Optional[CacheControl] = None
class ImageContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["image"]
source: Base64ImageSource | URLImageSource | FileImageSource
cache_control: Optional[CacheControl] = None
class ThinkingContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["thinking"]
thinking: str
# redacted_thinking 块:API 可能返回被审查的思考内容
class RedactedThinkingContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["redacted_thinking"]
data: str
class ToolUseContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["tool_use"]
id: str
name: str
input: Dict[str, Any]
cache_control: Optional[CacheControl] = None
class ToolResultContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["tool_result"]
tool_use_id: str
content: str | List[TextContent | ImageContent]
is_error: Optional[bool] = False
cache_control: Optional[CacheControl] = None
class ServerToolUseContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["server_tool_use"]
id: str
name: str
input: Dict[str, Any]
cache_control: Optional[CacheControl] = None
class WebSearchToolResultContent(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["web_search_tool_result"]
tool_use_id: str
content: List[WebSearchResult]
cache_control: Optional[CacheControl] = None
ContentBlock = Union[
TextContent,
ImageContent,
ThinkingContent,
RedactedThinkingContent,
ToolUseContent,
ToolResultContent,
ServerToolUseContent,
WebSearchToolResultContent,
]
class InputMessage(BaseModel):
model_config = ConfigDict(extra="allow")
role: Role
content: Union[str, List[ContentBlock]]
class ThinkingOptions(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["enabled", "disabled", "adaptive"] = "disabled"
budget_tokens: Optional[int] = None
class ToolChoice(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["auto", "any", "tool", "none"] = "auto"
name: Optional[str] = None
disable_parallel_tool_use: Optional[bool] = None
class CustomToolSpec(BaseModel):
model_config = ConfigDict(extra="allow")
description: Optional[str] = None
input_schema: Optional[Any] = None
class Tool(BaseModel):
model_config = ConfigDict(extra="allow")
type: Optional[str] = None
name: Optional[str] = None
input_schema: Optional[Any] = None
description: Optional[str] = None
custom: Optional[CustomToolSpec] = None
class OutputConfig(BaseModel):
"""Output configuration (effort, format, etc). effort and structured outputs are now GA."""
model_config = ConfigDict(extra="allow")
effort: Optional[Literal["low", "medium", "high", "max"]] = None
class OutputFormat(BaseModel):
"""Output format for structured outputs (deprecated, use output_config.format instead)."""
model_config = ConfigDict(extra="allow", populate_by_name=True, serialize_by_alias=True)
type: Literal["json_schema"]
schema_: Optional[Dict[str, Any]] = Field(default=None, alias="schema")
class ServerToolUsage(BaseModel):
model_config = ConfigDict(extra="allow")
web_search_requests: Optional[int] = None
class Usage(BaseModel):
model_config = ConfigDict(extra="allow")
input_tokens: int
output_tokens: int
cache_creation_input_tokens: Optional[int] = 0
cache_read_input_tokens: Optional[int] = 0
server_tool_use: Optional[ServerToolUsage] = None
class MessagesAPIRequest(BaseModel):
model_config = ConfigDict(extra="allow")
model: str = Field(default="claude-opus-4-20250514")
messages: List[InputMessage]
max_tokens: int = Field(default=8192, ge=1)
system: Optional[str | List[TextContent]] = None
temperature: Optional[float] = Field(default=None, ge=0, le=1)
top_p: Optional[float] = Field(default=None, ge=0, le=1)
top_k: Optional[int] = Field(default=None, ge=0)
stop_sequences: Optional[List[str]] = None
stream: Optional[bool] = False
metadata: Optional[Dict[str, Any]] = None
thinking: Optional[ThinkingOptions] = None
tool_choice: Optional[ToolChoice] = None
tools: Optional[List[Tool]] = None
output_config: Optional[OutputConfig] = None
output_format: Optional[OutputFormat] = None
@model_validator(mode="after")
def validate_thinking_tokens(self) -> "MessagesAPIRequest":
"""Ensure max_tokens > thinking.budget_tokens when thinking is enabled."""
if (
self.thinking
and self.thinking.type == "enabled"
and self.thinking.budget_tokens is not None
and self.max_tokens <= self.thinking.budget_tokens
):
self.max_tokens = self.thinking.budget_tokens + 1
return self
class Message(BaseModel):
model_config = ConfigDict(extra="allow")
id: str
type: Literal["message"]
role: Literal["assistant"]
content: List[ContentBlock]
model: str
stop_reason: Optional[
Literal[
"end_turn",
"max_tokens",
"stop_sequence",
"tool_use",
"pause_turn",
"refusal",
]
] = None
stop_sequence: Optional[str] = None
usage: Optional[Usage] = None
================================================
FILE: app/models/internal.py
================================================
from typing import List, Optional
from pydantic import BaseModel, Field
from .claude import Tool
class Attachment(BaseModel):
extracted_content: str
file_name: str
file_type: str
file_size: int
@classmethod
def from_text(cls, content: str) -> "Attachment":
"""Create text attachment."""
return cls(
extracted_content=content,
file_name="paste.txt",
file_type="txt",
file_size=len(content),
)
class ClaudeWebRequest(BaseModel):
max_tokens_to_sample: int
attachments: List[Attachment]
files: List[str] = Field(default_factory=list)
model: Optional[str] = None
rendering_mode: str = "messages"
prompt: str = ""
timezone: str
tools: List[Tool] = Field(default_factory=list)
class UploadResponse(BaseModel):
file_uuid: str
================================================
FILE: app/models/streaming.py
================================================
from typing import Optional, Union, Dict, Any, Literal
from pydantic import BaseModel, RootModel, ConfigDict
from .claude import ContentBlock, Message, Usage
# Base event types
class BaseEvent(BaseModel):
model_config = ConfigDict(extra="allow")
type: str
# Delta types
class TextDelta(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["text_delta"]
text: str
class InputJsonDelta(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["input_json_delta"]
partial_json: str
class ThinkingDelta(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["thinking_delta"]
thinking: str
class SignatureDelta(BaseModel):
model_config = ConfigDict(extra="allow")
type: Literal["signature_delta"]
signature: str
Delta = Union[TextDelta, InputJsonDelta, ThinkingDelta, SignatureDelta]
class MessageDeltaData(BaseModel):
model_config = ConfigDict(extra="allow")
stop_reason: Optional[
Literal["end_turn", "max_tokens", "stop_sequence", "tool_use", "pause_turn", "refusal"]
] = None
stop_sequence: Optional[str] = None
# Error model
class ErrorInfo(BaseModel):
model_config = ConfigDict(extra="allow")
type: str
message: str
# Event models
class MessageStartEvent(BaseEvent):
type: Literal["message_start"]
message: Message
class ContentBlockStartEvent(BaseEvent):
type: Literal["content_block_start"]
index: int
content_block: ContentBlock
class ContentBlockDeltaEvent(BaseEvent):
type: Literal["content_block_delta"]
index: int
delta: Delta
class ContentBlockStopEvent(BaseEvent):
type: Literal["content_block_stop"]
index: int
class MessageDeltaEvent(BaseEvent):
type: Literal["message_delta"]
delta: MessageDeltaData
usage: Optional[Usage] = None
class MessageStopEvent(BaseEvent):
type: Literal["message_stop"]
class PingEvent(BaseEvent):
type: Literal["ping"]
class ErrorEvent(BaseEvent):
type: Literal["error"]
error: ErrorInfo
class UnknownEvent(BaseEvent):
type: str
data: Dict[str, Any]
# Union of all streaming event types
class StreamingEvent(RootModel):
root: Union[
MessageStartEvent,
ContentBlockStartEvent,
ContentBlockDeltaEvent,
ContentBlockStopEvent,
MessageDeltaEvent,
MessageStopEvent,
PingEvent,
ErrorEvent,
UnknownEvent,
]
================================================
FILE: app/processors/__init__.py
================================================
from app.processors.base import BaseProcessor, BaseContext
from app.processors.claude_ai import (
ClaudeAIContext,
TestMessageProcessor,
ClaudeWebProcessor,
EventParsingProcessor,
StreamingResponseProcessor,
MessageCollectorProcessor,
NonStreamingResponseProcessor,
TokenCounterProcessor,
ToolResultProcessor,
ToolCallEventProcessor,
StopSequencesProcessor,
)
__all__ = [
# Base classes
"BaseProcessor",
"BaseContext",
# Claude AI Pipeline
"ClaudeAIContext",
"TestMessageProcessor",
"ClaudeWebProcessor",
"EventParsingProcessor",
"StreamingResponseProcessor",
"MessageCollectorProcessor",
"NonStreamingResponseProcessor",
"TokenCounterProcessor",
"ToolResultProcessor",
"ToolCallEventProcessor",
"StopSequencesProcessor",
]
================================================
FILE: app/processors/base.py
================================================
"""Base classes for request processing pipeline."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
from fastapi import Request
from fastapi.responses import StreamingResponse, JSONResponse
@dataclass
class BaseContext:
"""Base context passed between processors in the pipeline."""
original_request: Request
response: Optional[StreamingResponse | JSONResponse] = None
metadata: dict = field(
default_factory=dict
) # For storing custom data between processors
class BaseProcessor(ABC):
"""Base class for all request processors."""
@abstractmethod
async def process(self, context: BaseContext) -> BaseContext:
"""
Process the request context.
Args:
context: The processing context
Returns:
Updated context.
"""
pass
@property
def name(self) -> str:
"""Get the processor name."""
return self.__class__.__name__
================================================
FILE: app/processors/claude_ai/__init__.py
================================================
from app.processors.claude_ai.context import ClaudeAIContext
from app.processors.claude_ai.pipeline import ClaudeAIPipeline
from app.processors.claude_ai.tavern_test_message_processor import TestMessageProcessor
from app.processors.claude_ai.claude_web_processor import ClaudeWebProcessor
from app.processors.claude_ai.claude_api_processor import ClaudeAPIProcessor
from app.processors.claude_ai.event_parser_processor import EventParsingProcessor
from app.processors.claude_ai.streaming_response_processor import (
StreamingResponseProcessor,
)
from app.processors.claude_ai.message_collector_processor import (
MessageCollectorProcessor,
)
from app.processors.claude_ai.non_streaming_response_processor import (
NonStreamingResponseProcessor,
)
from app.processors.claude_ai.token_counter_processor import TokenCounterProcessor
from app.processors.claude_ai.tool_result_processor import ToolResultProcessor
from app.processors.claude_ai.tool_call_event_processor import ToolCallEventProcessor
from app.processors.claude_ai.stop_sequences_processor import StopSequencesProcessor
from app.processors.claude_ai.model_injector_processor import ModelInjectorProcessor
__all__ = [
"ClaudeAIContext",
"ClaudeAIPipeline",
"TestMessageProcessor",
"ClaudeWebProcessor",
"ClaudeAPIProcessor",
"EventParsingProcessor",
"StreamingResponseProcessor",
"MessageCollectorProcessor",
"NonStreamingResponseProcessor",
"TokenCounterProcessor",
"ToolResultProcessor",
"ToolCallEventProcessor",
"StopSequencesProcessor",
"ModelInjectorProcessor",
]
================================================
FILE: app/processors/claude_ai/claude_api_processor.py
================================================
from app.core.http_client import (
Response,
AsyncSession,
create_session,
)
from datetime import datetime, timedelta, UTC
from typing import Dict
from loguru import logger
from fastapi.responses import StreamingResponse
from app.models.claude import MessagesAPIRequest, TextContent
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.services.account import account_manager
from app.services.cache import cache_service
from app.core.exceptions import (
ClaudeHttpError,
ClaudeRateLimitedError,
InvalidModelNameError,
NoAccountsAvailableError,
OAuthAuthenticationNotAllowedError,
)
from app.core.config import settings
class ClaudeAPIProcessor(BaseProcessor):
"""Processor that calls Claude Messages API directly using OAuth authentication."""
def __init__(self):
self.messages_api_url = (
settings.claude_api_baseurl.encoded_string().rstrip("/") + "/v1/messages"
)
async def _request_messages_api(
self, session: AsyncSession, request_json: str, headers: Dict[str, str]
) -> Response:
"""Make HTTP request with retry mechanism for curl_cffi exceptions."""
response: Response = await session.request(
"POST",
self.messages_api_url,
data=request_json,
headers=headers,
stream=True,
)
return response
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Process Claude API request using OAuth authentication.
Requires:
- messages_api_request in context
Produces:
- response in context (StreamingResponse)
"""
if context.response:
logger.debug("Skipping ClaudeAPIProcessor due to existing response")
return context
if not context.messages_api_request:
logger.warning(
"Skipping ClaudeAPIProcessor due to missing messages_api_request"
)
return context
self._insert_system_message(context)
try:
# First try to get account from cache service
cached_account_id, checkpoints = cache_service.process_messages(
context.messages_api_request.model,
context.messages_api_request.messages,
context.messages_api_request.system,
)
account = None
if cached_account_id:
account = await account_manager.get_account_by_id(cached_account_id)
if account:
logger.info(f"Using cached account: {cached_account_id[:8]}...")
# If no cached account or account not available, get a new one
if not account:
account = await account_manager.get_account_for_oauth(
is_max=True
if (context.messages_api_request.model in settings.max_models)
else None
)
with account:
request_json = context.messages_api_request.model_dump_json(
exclude_none=True
)
headers = self._prepare_headers(
account.oauth_token.access_token,
context.messages_api_request,
context.original_request,
)
session = create_session(
proxy=settings.proxy_url,
timeout=settings.request_timeout,
impersonate="chrome",
follow_redirects=False,
)
response = await self._request_messages_api(
session, request_json, headers
)
resets_at = response.headers.get("anthropic-ratelimit-unified-reset")
if resets_at:
try:
resets_at = int(resets_at)
account.resets_at = datetime.fromtimestamp(resets_at, tz=UTC)
except ValueError:
logger.error(
f"Invalid resets_at format from Claude API: {resets_at}"
)
account.resets_at = None
# Handle rate limiting
if response.status_code == 429:
next_hour = datetime.now(UTC).replace(
minute=0, second=0, microsecond=0
) + timedelta(hours=1)
raise ClaudeRateLimitedError(
resets_at=account.resets_at or next_hour
)
if response.status_code >= 400:
error_data = await response.json()
if (
response.status_code == 400
and error_data.get("error", {}).get("message")
== "system: Invalid model name"
):
raise InvalidModelNameError(context.messages_api_request.model)
if (
response.status_code == 401
and error_data.get("error", {}).get("message")
== "OAuth authentication is currently not allowed for this organization."
):
raise OAuthAuthenticationNotAllowedError()
logger.error(
f"Claude API error: {response.status_code} - {error_data}"
)
raise ClaudeHttpError(
url=self.messages_api_url,
status_code=response.status_code,
error_type=error_data.get("error", {}).get("type", "unknown"),
error_message=error_data.get("error", {}).get(
"message", "Unknown error"
),
)
async def stream_response():
async for chunk in response.aiter_bytes():
yield chunk
await session.close()
filtered_headers = {}
for key, value in response.headers.items():
if key.lower() in ["content-encoding", "content-length"]:
logger.debug(f"Filtering out header: {key}: {value}")
continue
filtered_headers[key] = value
context.response = StreamingResponse(
stream_response(),
status_code=response.status_code,
headers=filtered_headers,
)
# Stop pipeline on success
context.metadata["stop_pipeline"] = True
logger.info("Successfully processed request via Claude API")
# Store checkpoints in cache service after successful request
if checkpoints and account:
cache_service.add_checkpoints(
checkpoints, account.organization_uuid
)
except (NoAccountsAvailableError, InvalidModelNameError):
logger.debug("No accounts available for Claude API, continuing pipeline")
return context
def _insert_system_message(self, context: ClaudeAIContext) -> None:
"""Insert system message into the request."""
request = context.messages_api_request
# Handle system field
system_message_text = (
"You are Claude Code, Anthropic's official CLI for Claude."
)
system_message = TextContent(type="text", text=system_message_text)
if isinstance(request.system, str) and request.system:
request.system = [
system_message,
TextContent(type="text", text=request.system),
]
elif isinstance(request.system, list) and request.system:
if request.system[0].text == system_message_text:
logger.debug("System message already exists, skipping injection.")
else:
request.system = [system_message] + request.system
else:
request.system = [system_message]
def _prepare_headers(
self,
access_token: str,
request: MessagesAPIRequest,
original_request=None,
) -> Dict[str, str]:
"""Prepare headers for Claude API request.
Beta headers: oauth 是 OAuth 认证必需的。
effort 和 structured-outputs 已 GA,不再需要 beta header。
客户端的 anthropic-beta header 会被透传(去重合并)。
"""
# oauth beta 是 OAuth 认证必需的
beta_features = ["oauth-2025-04-20"]
# 透传客户端 anthropic-beta header,与内部 beta 去重合并
if original_request:
client_beta = original_request.headers.get("anthropic-beta", "")
if client_beta:
for beta in client_beta.split(","):
beta = beta.strip()
if beta and beta not in beta_features:
beta_features.append(beta)
return {
"Authorization": f"Bearer {access_token}",
"anthropic-beta": ",".join(beta_features),
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
}
================================================
FILE: app/processors/claude_ai/claude_web_processor.py
================================================
import time
import base64
import random
import string
from typing import List
from loguru import logger
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.services.session import session_manager
from app.models.internal import ClaudeWebRequest, Attachment
from app.core.exceptions import NoValidMessagesError
from app.core.config import settings
from app.utils.messages import process_messages
class ClaudeWebProcessor(BaseProcessor):
"""Claude AI processor that handles session management, request building, and sending to Claude AI."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Claude AI processor that:
1. Gets or creates a Claude session
2. Builds ClaudeWebRequest from messages_api_request
3. Sends the request to Claude.ai
Requires:
- messages_api_request in context
Produces:
- claude_session in context
- claude_web_request in context
- original_stream in context
"""
if context.original_stream:
logger.debug("Skipping ClaudeWebProcessor due to existing original_stream")
return context
if not context.messages_api_request:
logger.warning(
"Skipping ClaudeWebProcessor due to missing messages_api_request"
)
return context
# Step 1: Get or create Claude session
if not context.claude_session:
session_id = context.metadata.get("session_id")
if not session_id:
session_id = f"session_{int(time.time() * 1000)}"
context.metadata["session_id"] = session_id
logger.debug(f"Creating new session: {session_id}")
context.claude_session = await session_manager.get_or_create_session(
session_id
)
# Step 2: Build ClaudeWebRequest
if not context.claude_web_request:
request = context.messages_api_request
if not request.messages:
raise NoValidMessagesError()
merged_text, images = await process_messages(
request.messages, request.system
)
if not merged_text:
raise NoValidMessagesError()
if settings.padtxt_length > 0:
pad_tokens = settings.pad_tokens or (
string.ascii_letters + string.digits
)
pad_text = "".join(random.choices(pad_tokens, k=settings.padtxt_length))
merged_text = pad_text + merged_text
logger.debug(
f"Added {settings.padtxt_length} padding tokens to the beginning of the message"
)
image_file_ids: List[str] = []
if images:
for i, image_source in enumerate(images):
try:
# Convert base64 to bytes
image_data = base64.b64decode(image_source.data)
# Upload to Claude
file_id = await context.claude_session.upload_file(
file_data=image_data,
filename=f"image_{i}.png", # Default filename
content_type=image_source.media_type,
)
image_file_ids.append(file_id)
logger.debug(f"Uploaded image {i}: {file_id}")
except Exception as e:
logger.error(f"Failed to upload image {i}: {e}")
await context.claude_session._ensure_conversation_initialized()
paprika_mode = (
"extended"
if (
context.claude_session.account.is_pro
and request.thinking
and request.thinking.type in ("enabled", "adaptive")
)
else None
)
await context.claude_session.set_paprika_mode(paprika_mode)
web_request = ClaudeWebRequest(
max_tokens_to_sample=request.max_tokens,
attachments=[Attachment.from_text(merged_text)],
files=image_file_ids,
model=request.model,
rendering_mode="messages",
prompt=settings.custom_prompt or "",
timezone="UTC",
tools=request.tools or [],
)
context.claude_web_request = web_request
logger.debug(f"Built web request with {len(image_file_ids)} images")
# Step 3: Send to Claude
logger.debug(
f"Sending request to Claude.ai for session {context.claude_session.session_id}"
)
request_dict = context.claude_web_request.model_dump(exclude_none=True)
context.original_stream = await context.claude_session.send_message(
request_dict
)
return context
================================================
FILE: app/processors/claude_ai/context.py
================================================
from dataclasses import dataclass
from typing import Optional, AsyncIterator
from app.core.claude_session import ClaudeWebSession
from app.models.claude import Message, MessagesAPIRequest
from app.models.internal import ClaudeWebRequest
from app.models.streaming import StreamingEvent
from app.processors.base import BaseContext
@dataclass
class ClaudeAIContext(BaseContext):
messages_api_request: Optional[MessagesAPIRequest] = None
claude_web_request: Optional[ClaudeWebRequest] = None
claude_session: Optional[ClaudeWebSession] = None
original_stream: Optional[AsyncIterator[str]] = None
event_stream: Optional[AsyncIterator[StreamingEvent]] = None
collected_message: Optional[Message] = None
================================================
FILE: app/processors/claude_ai/event_parser_processor.py
================================================
from loguru import logger
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.services.event_processing.event_parser import EventParser
class EventParsingProcessor(BaseProcessor):
"""Processor that parses SSE streams into StreamingEvent objects."""
def __init__(self):
super().__init__()
self.parser = EventParser()
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Parse the original_stream into event_stream.
Requires:
- original_stream in context
Produces:
- event_stream in context
"""
if context.event_stream:
logger.debug("Skipping EventParsingProcessor due to existing event_stream")
return context
if not context.original_stream:
logger.warning(
"Skipping EventParsingProcessor due to missing original_stream"
)
return context
logger.debug("Starting event parsing from SSE stream")
context.event_stream = self.parser.parse_stream(context.original_stream)
return context
================================================
FILE: app/processors/claude_ai/message_collector_processor.py
================================================
import json5
from typing import AsyncIterator
from loguru import logger
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.models.streaming import (
Delta,
StreamingEvent,
MessageStartEvent,
ContentBlockStartEvent,
ContentBlockDeltaEvent,
ContentBlockStopEvent,
MessageDeltaEvent,
MessageStopEvent,
ErrorEvent,
ErrorInfo,
TextDelta,
InputJsonDelta,
ThinkingDelta,
)
from app.models.claude import (
ContentBlock,
ServerToolUseContent,
TextContent,
ThinkingContent,
ToolResultContent,
ToolUseContent,
)
class MessageCollectorProcessor(BaseProcessor):
"""Processor that collects streaming events into a Message object without consuming the stream."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Collect streaming events into a Message object and update it in real-time.
This processor runs for both streaming and non-streaming requests.
Requires:
- event_stream in context
Produces:
- collected_message in context (updated in real-time)
- event_stream in context (wrapped to collect messages without consuming)
"""
if not context.event_stream:
logger.warning(
"Skipping MessageCollectorProcessor due to missing event_stream"
)
return context
logger.debug("Setting up message collection from stream")
original_stream = context.event_stream
new_stream = self._collect_messages_generator(original_stream, context)
context.event_stream = new_stream
return context
async def _collect_messages_generator(
self, event_stream: AsyncIterator[StreamingEvent], context: ClaudeAIContext
) -> AsyncIterator[StreamingEvent]:
"""
Generator that collects messages from the stream without consuming events.
Updates context.collected_message in real-time.
"""
context.collected_message = None
async for event in event_stream:
# Process the event to build/update the message
if isinstance(event.root, MessageStartEvent):
context.collected_message = event.root.message.model_copy(deep=True)
logger.debug(f"Message started: {context.collected_message.id}")
elif isinstance(event.root, ContentBlockStartEvent):
if context.collected_message:
while len(context.collected_message.content) <= event.root.index:
context.collected_message.content.append(None)
context.collected_message.content[event.root.index] = (
event.root.content_block.model_copy(deep=True)
)
logger.debug(
f"Content block {event.root.index} started: {event.root.content_block.type}"
)
elif isinstance(event.root, ContentBlockDeltaEvent):
if context.collected_message and event.root.index < len(
context.collected_message.content
):
self._apply_delta(
context.collected_message.content[event.root.index],
event.root.delta,
)
elif isinstance(event.root, ContentBlockStopEvent):
# Boundary checking to prevent IndexError caused by refusal responses
if (
context.collected_message
and event.root.index < len(context.collected_message.content)
):
block = context.collected_message.content[event.root.index]
if isinstance(block, (ToolUseContent, ServerToolUseContent)):
if hasattr(block, "input_json") and block.input_json:
block.input = json5.loads(block.input_json)
del block.input_json
if isinstance(block, ToolResultContent):
if hasattr(block, "content_json") and block.content_json:
block = ToolResultContent(
**block.model_dump(exclude={"content"}),
content=json5.loads(block.content_json),
)
del block.content_json
context.collected_message.content[event.root.index] = block
logger.debug(f"Content block {event.root.index} stopped")
else:
logger.debug(
f"Content block {event.root.index} stop skipped (no corresponding start)"
)
elif isinstance(event.root, MessageDeltaEvent):
if context.collected_message and event.root.delta:
if event.root.delta.stop_reason:
context.collected_message.stop_reason = (
event.root.delta.stop_reason
)
# When refusal is detected and content is empty, yield ErrorEvent
if (
event.root.delta.stop_reason == "refusal"
and not context.collected_message.content
):
logger.warning("Request refused by Claude's safety filter")
error_event = StreamingEvent(
root=ErrorEvent(
type="error",
error=ErrorInfo(
type="refusal",
message="Chat paused: Claude's safety filters flagged this message. This occasionally happens with normal, safe messages. Try rephrasing or using a different model."
)
)
)
yield error_event
if event.root.delta.stop_sequence:
context.collected_message.stop_sequence = (
event.root.delta.stop_sequence
)
if context.collected_message and event.root.usage:
context.collected_message.usage = event.root.usage
elif isinstance(event.root, MessageStopEvent):
if context.collected_message:
context.collected_message.content = [
block
for block in context.collected_message.content
if block is not None
]
logger.debug(
f"Message stopped with {len(context.collected_message.content)} content blocks"
)
elif isinstance(event.root, ErrorEvent):
logger.warning(f"Error event received: {event.root.error.message}")
# Yield the event without modification
yield event
if context.collected_message:
logger.debug(
f"Collected message:\n{context.collected_message.model_dump()}"
)
def _apply_delta(self, content_block: ContentBlock, delta: Delta) -> None:
"""Apply a delta to a content block."""
if isinstance(delta, TextDelta):
if isinstance(content_block, TextContent):
content_block.text += delta.text
elif isinstance(delta, ThinkingDelta):
if isinstance(content_block, ThinkingContent):
content_block.thinking += delta.thinking
elif isinstance(delta, InputJsonDelta):
if isinstance(content_block, (ToolUseContent, ServerToolUseContent)):
if hasattr(content_block, "input_json"):
content_block.input_json += delta.partial_json
else:
content_block.input_json = delta.partial_json
if isinstance(content_block, ToolResultContent):
if hasattr(content_block, "content_json"):
content_block.content_json += delta.partial_json
else:
content_block.content_json = delta.partial_json
================================================
FILE: app/processors/claude_ai/model_injector_processor.py
================================================
from typing import AsyncIterator
from loguru import logger
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.models.streaming import (
MessageStartEvent,
StreamingEvent,
)
class ModelInjectorProcessor(BaseProcessor):
"""Processor that injects model information when it's missing from MessageStartEvent."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Intercept MessageStartEvent and add model information if missing.
Requires:
- event_stream in context
- messages_api_request in context (for model information)
Produces:
- event_stream with updated MessageStartEvent containing model
"""
if not context.event_stream:
logger.warning(
"Skipping ModelInjectorProcessor due to missing event_stream"
)
return context
if not context.messages_api_request:
logger.warning(
"Skipping ModelInjectorProcessor due to missing messages_api_request"
)
return context
logger.debug("Setting up model injection for stream")
original_stream = context.event_stream
new_stream = self._inject_model_generator(original_stream, context)
context.event_stream = new_stream
return context
async def _inject_model_generator(
self,
event_stream: AsyncIterator[StreamingEvent],
context: ClaudeAIContext,
) -> AsyncIterator[StreamingEvent]:
"""
Generator that adds model to MessageStartEvent if missing.
"""
# Get model from request
model = context.messages_api_request.model
async for event in event_stream:
if isinstance(event.root, MessageStartEvent):
# Check if model is missing or empty
if not event.root.message.model:
event.root.message.model = model
logger.debug(f"Injected model '{model}' into MessageStartEvent")
else:
logger.debug(
f"MessageStartEvent already has model: '{event.root.message.model}'"
)
yield event
================================================
FILE: app/processors/claude_ai/non_streaming_response_processor.py
================================================
from loguru import logger
from fastapi.responses import JSONResponse
from app.core.exceptions import ClaudeStreamingError, NoMessageError
from app.models.streaming import ErrorEvent
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
class NonStreamingResponseProcessor(BaseProcessor):
"""Processor that builds a non-streaming JSON response from collected message."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Build a non-streaming JSON response from the collected message.
This processor only runs for non-streaming requests.
Requires:
- messages_api_request with stream=False
- collected_message in context (must consume entire stream first)
Produces:
- response (JSONResponse) in context
"""
if context.response:
logger.debug(
"Skipping NonStreamingResponseProcessor due to existing response"
)
return context
if context.messages_api_request and context.messages_api_request.stream is True:
logger.debug("Skipping NonStreamingResponseProcessor for streaming request")
return context
if not context.event_stream:
logger.warning(
"Skipping NonStreamingResponseProcessor due to missing event_stream"
)
return context
logger.info("Building non-streaming response")
# Consume the entire stream to ensure collected_message is complete
async for event in context.event_stream:
if isinstance(event.root, ErrorEvent):
raise ClaudeStreamingError(
error_type=event.root.error.type,
error_message=event.root.error.message,
)
if not context.collected_message:
logger.error("No message collected after consuming stream")
raise NoMessageError()
context.response = JSONResponse(
content=context.collected_message.model_dump(exclude_none=True),
headers={
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
)
return context
================================================
FILE: app/processors/claude_ai/pipeline.py
================================================
from typing import List, Optional
from loguru import logger
from app.services.session import session_manager
from app.processors.pipeline import ProcessingPipeline
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.processors.claude_ai.tavern_test_message_processor import TestMessageProcessor
from app.processors.claude_ai.claude_web_processor import ClaudeWebProcessor
from app.processors.claude_ai.claude_api_processor import ClaudeAPIProcessor
from app.processors.claude_ai.event_parser_processor import EventParsingProcessor
from app.processors.claude_ai.streaming_response_processor import (
StreamingResponseProcessor,
)
from app.processors.claude_ai.message_collector_processor import (
MessageCollectorProcessor,
)
from app.processors.claude_ai.non_streaming_response_processor import (
NonStreamingResponseProcessor,
)
from app.processors.claude_ai.token_counter_processor import TokenCounterProcessor
from app.processors.claude_ai.tool_result_processor import ToolResultProcessor
from app.processors.claude_ai.tool_call_event_processor import ToolCallEventProcessor
from app.processors.claude_ai.stop_sequences_processor import StopSequencesProcessor
from app.processors.claude_ai.model_injector_processor import ModelInjectorProcessor
class ClaudeAIPipeline(ProcessingPipeline):
def __init__(self, processors: Optional[List[BaseProcessor]] = None):
"""
Initialize the pipeline with processors.
Args:
processors: List of processors to use. If None, default processors are used.
"""
processors = (
[
TestMessageProcessor(),
ToolResultProcessor(),
ClaudeAPIProcessor(),
ClaudeWebProcessor(),
EventParsingProcessor(),
ModelInjectorProcessor(),
StopSequencesProcessor(),
ToolCallEventProcessor(),
MessageCollectorProcessor(),
TokenCounterProcessor(),
StreamingResponseProcessor(),
NonStreamingResponseProcessor(),
]
if processors is None
else processors
)
super().__init__(processors)
async def process(
self,
context: ClaudeAIContext,
) -> ClaudeAIContext:
"""
Process a Claude API request through the pipeline.
Args:
context: The processing context
Returns:
Updated context.
Raises:
Exception: If any processor fails or no response is generated
"""
try:
return await super().process(context)
except Exception as e:
if context.claude_session:
await session_manager.remove_session(context.claude_session.session_id)
logger.error(f"Pipeline processing failed: {e}")
raise e
================================================
FILE: app/processors/claude_ai/stop_sequences_processor.py
================================================
from typing import AsyncIterator, List
from loguru import logger
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.models.streaming import (
StreamingEvent,
ContentBlockDeltaEvent,
ContentBlockStopEvent,
MessageDeltaEvent,
MessageStopEvent,
MessageDeltaData,
TextDelta,
)
from app.services.session import session_manager
class StopSequencesProcessor(BaseProcessor):
"""Processor that handles stop sequences in streaming responses."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Process streaming events to detect and handle stop sequences.
Requires:
- event_stream in context
- messages_api_request in context (for stop_sequences)
Produces:
- Modified event_stream that stops when a stop sequence is detected
- Injects MessageDelta and MessageStop events when stop sequence found
"""
if not context.event_stream:
logger.warning(
"Skipping StopSequencesProcessor due to missing event_stream"
)
return context
if not context.messages_api_request:
logger.warning(
"Skipping StopSequencesProcessor due to missing messages_api_request"
)
return context
stop_sequences = context.messages_api_request.stop_sequences
if not stop_sequences:
logger.debug("No stop sequences configured, skipping processor")
return context
logger.debug(f"Setting up stop sequences processing for: {stop_sequences}")
original_stream = context.event_stream
new_stream = self._process_stop_sequences(
original_stream, stop_sequences, context
)
context.event_stream = new_stream
return context
async def _process_stop_sequences(
self,
event_stream: AsyncIterator[StreamingEvent],
stop_sequences: List[str],
context: ClaudeAIContext,
) -> AsyncIterator[StreamingEvent]:
"""
Process events and stop when a stop sequence is detected.
Uses incremental matching with buffering.
"""
stop_sequences_set = set(stop_sequences)
buffer = ""
current_index = 0
# Track potential matches: (start_position, current_matched_text)
potential_matches = []
async for event in event_stream:
if isinstance(event.root, ContentBlockDeltaEvent) and isinstance(
event.root.delta, TextDelta
):
text = event.root.delta.text
current_index = event.root.index
for char in text:
buffer += char
current_pos = len(buffer) - 1
potential_matches.append((current_pos, ""))
new_matches = []
for start_pos, matched_text in potential_matches:
extended_match = matched_text + char
could_match = False
for stop_seq in stop_sequences:
if stop_seq.startswith(extended_match):
could_match = True
break
if could_match:
new_matches.append((start_pos, extended_match))
if extended_match in stop_sequences_set:
logger.debug(
f"Stop sequence detected: '{extended_match}'"
)
safe_text = buffer[:start_pos]
if safe_text:
yield StreamingEvent(
root=ContentBlockDeltaEvent(
type="content_block_delta",
index=current_index,
delta=TextDelta(
type="text_delta", text=safe_text
),
)
)
yield StreamingEvent(
root=ContentBlockStopEvent(
type="content_block_stop", index=current_index
)
)
yield StreamingEvent(
root=MessageDeltaEvent(
type="message_delta",
delta=MessageDeltaData(
stop_reason="stop_sequence",
stop_sequence=extended_match,
),
usage=None,
)
)
yield StreamingEvent(
root=MessageStopEvent(type="message_stop")
)
if context.claude_session:
await session_manager.remove_session(
context.claude_session.session_id
)
return
potential_matches = new_matches
if potential_matches:
earliest_start = min(
start_pos for start_pos, _ in potential_matches
)
safe_length = earliest_start
else:
safe_length = len(buffer)
if safe_length > 0:
safe_text = buffer[:safe_length]
yield StreamingEvent(
root=ContentBlockDeltaEvent(
type="content_block_delta",
index=current_index,
delta=TextDelta(type="text_delta", text=safe_text),
)
)
buffer = buffer[safe_length:]
new_matches = []
for start_pos, matched_text in potential_matches:
new_start = start_pos - safe_length
if new_start >= 0:
new_matches.append((new_start, matched_text))
potential_matches = new_matches
else:
# Non-text event - flush buffer and reset
if buffer:
yield StreamingEvent(
root=ContentBlockDeltaEvent(
type="content_block_delta",
index=current_index,
delta=TextDelta(type="text_delta", text=buffer),
)
)
buffer = ""
potential_matches = []
yield event
================================================
FILE: app/processors/claude_ai/streaming_response_processor.py
================================================
from loguru import logger
from fastapi.responses import StreamingResponse
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.services.event_processing.event_serializer import EventSerializer
class StreamingResponseProcessor(BaseProcessor):
"""Processor that serializes event streams and creates a StreamingResponse."""
def __init__(self):
super().__init__()
self.serializer = EventSerializer()
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Serialize the event_stream and create a StreamingResponse.
Requires:
- event_stream in context
Produces:
- response in context
This processor typically marks the end of the pipeline by returning STOP action.
"""
if context.response:
logger.debug("Skipping StreamingResponseProcessor due to existing response")
return context
if not context.event_stream:
logger.warning(
"Skipping StreamingResponseProcessor due to missing event_stream"
)
return context
if (
not context.messages_api_request
or context.messages_api_request.stream is not True
):
logger.debug(
"Skipping StreamingResponseProcessor due to non-streaming request"
)
return context
logger.info("Creating streaming response from event stream")
sse_stream = self.serializer.serialize_stream(context.event_stream)
context.response = StreamingResponse(
sse_stream,
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
},
)
return context
================================================
FILE: app/processors/claude_ai/tavern_test_message_processor.py
================================================
from loguru import logger
import uuid
from fastapi.responses import JSONResponse
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.models.claude import (
Message,
Role,
TextContent,
Usage,
)
class TestMessageProcessor(BaseProcessor):
"""Processor that handles test messages."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Check if this is a test message and respond immediately if so.
Test message criteria:
- Only one message in messages array
- Message role is "user"
- Message content is "Hi"
- stream is False
If it's a test message, creates a MessagesAPIResponse and stops the pipeline.
"""
if not context.messages_api_request:
return context
request = context.messages_api_request
if (
len(request.messages) == 1
and request.messages[0].role == Role.USER
and request.stream is False
and (
(
isinstance(request.messages[0].content, str)
and request.messages[0].content == "Hi"
)
or (
isinstance(request.messages[0].content, list)
and len(request.messages[0].content) == 1
and isinstance(request.messages[0].content[0], TextContent)
and request.messages[0].content[0].text == "Hi"
)
)
):
logger.debug("Test message detected, returning canned response")
response = Message(
id=f"msg_{uuid.uuid4().hex[:10]}",
type="message",
role="assistant",
content=[
TextContent(type="text", text="Hello! How can I assist you today?")
],
model=request.model,
stop_reason="end_turn",
stop_sequence=None,
usage=Usage(input_tokens=1, output_tokens=9),
)
context.response = JSONResponse(
content=response.model_dump(), status_code=200
)
context.metadata["stop_pipeline"] = True
return context
return context
================================================
FILE: app/processors/claude_ai/token_counter_processor.py
================================================
from typing import AsyncIterator
from loguru import logger
import tiktoken
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.models.streaming import (
MessageStartEvent,
StreamingEvent,
MessageDeltaEvent,
)
from app.models.claude import Usage
from app.utils.messages import process_messages
encoder = tiktoken.get_encoding("cl100k_base")
class TokenCounterProcessor(BaseProcessor):
"""Processor that estimates token usage when it's not provided by the API."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Intercept MessageDeltaEvent and add token usage estimation if missing.
Requires:
- event_stream in context
- messages_api_request in context (for input token counting)
- collected_message in context (for output token counting)
Produces:
- event_stream with updated MessageDeltaEvent containing usage
"""
if not context.event_stream:
logger.warning("Skipping TokenCounterProcessor due to missing event_stream")
return context
if not context.messages_api_request:
logger.warning(
"Skipping TokenCounterProcessor due to missing messages_api_request"
)
return context
logger.debug("Setting up token counting for stream")
original_stream = context.event_stream
new_stream = self._count_tokens_generator(original_stream, context)
context.event_stream = new_stream
return context
async def _count_tokens_generator(
self,
event_stream: AsyncIterator[StreamingEvent],
context: ClaudeAIContext,
) -> AsyncIterator[StreamingEvent]:
"""
Generator that adds token usage to MessageDeltaEvent if missing.
"""
# Pre-calculate input tokens once
input_tokens = await self._calculate_input_tokens(context)
async for event in event_stream:
if (
isinstance(event.root, MessageStartEvent)
and not event.root.message.usage
):
usage = Usage(
input_tokens=input_tokens,
output_tokens=1,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
)
event.root.message.usage = usage
context.collected_message.usage = usage
logger.debug(f"Added token usage estimation: input={input_tokens}")
if isinstance(event.root, MessageDeltaEvent) and not event.root.usage:
output_tokens = await self._calculate_output_tokens(context)
usage = Usage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
)
event.root.usage = usage
context.collected_message.usage = usage
logger.debug(
f"Added token usage estimation: input={input_tokens}, output={output_tokens}"
)
yield event
async def _calculate_input_tokens(self, context: ClaudeAIContext) -> int:
"""Calculate input tokens from the request messages."""
if not context.messages_api_request:
return 0
merged_text, _ = await process_messages(
context.messages_api_request.messages, context.messages_api_request.system
)
try:
tokens = len(encoder.encode(merged_text, disallowed_special=()))
except Exception:
logger.warning("Tiktoken encoding failed for input, falling back to estimation")
tokens = len(merged_text) // 4
logger.debug(f"Calculated {tokens} input tokens")
return tokens
async def _calculate_output_tokens(self, context: ClaudeAIContext) -> int:
"""Calculate output tokens from the collected message."""
if not context.collected_message:
return 0
merged_text, _ = await process_messages([context.collected_message])
try:
tokens = len(encoder.encode(merged_text, disallowed_special=()))
except Exception:
logger.warning("Tiktoken encoding failed for output, falling back to estimation")
tokens = len(merged_text) // 4
logger.debug(f"Calculated {tokens} output tokens")
return tokens
================================================
FILE: app/processors/claude_ai/tool_call_event_processor.py
================================================
from typing import AsyncIterator, Optional
from loguru import logger
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.models.streaming import (
StreamingEvent,
ContentBlockStartEvent,
ContentBlockStopEvent,
MessageDeltaEvent,
MessageStopEvent,
MessageDeltaData,
)
from app.models.claude import ToolResultContent, ToolUseContent
from app.services.tool_call import tool_call_manager
class ToolCallEventProcessor(BaseProcessor):
"""Processor that handles tool use events in the streaming response."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Intercept tool use content blocks and inject MessageDelta/MessageStop events.
Requires:
- event_stream in context
- cladue_session in context
Produces:
- Modified event_stream with injected events for tool calls
- Pauses session when tool call is detected
"""
if not context.event_stream:
logger.warning(
"Skipping ToolCallEventProcessor due to missing event_stream"
)
return context
if not context.claude_session:
logger.warning("Skipping ToolCallEventProcessor due to missing session")
return context
logger.debug("Setting up tool call event processing")
original_stream = context.event_stream
new_stream = self._process_tool_events(original_stream, context)
context.event_stream = new_stream
return context
async def _process_tool_events(
self,
event_stream: AsyncIterator[StreamingEvent],
context: ClaudeAIContext,
) -> AsyncIterator[StreamingEvent]:
"""
Process events and inject MessageDelta/MessageStop when tool use is detected.
"""
current_tool_use_id: Optional[str] = None
tool_use_detected = False
content_block_index: Optional[int] = None
tool_result_detected = False
async for event in event_stream:
# Check for ContentBlockStartEvent with tool_use type
if isinstance(event.root, ContentBlockStartEvent):
if isinstance(event.root.content_block, ToolUseContent):
current_tool_use_id = event.root.content_block.id
content_block_index = event.root.index
tool_use_detected = True
logger.debug(
f"Detected tool use start: {current_tool_use_id} "
f"(name: {event.root.content_block.name})"
)
elif isinstance(event.root.content_block, ToolResultContent):
logger.debug(
f"Detected tool result: {event.root.content_block.tool_use_id}"
)
tool_result_detected = True
# Yield the original event
if tool_result_detected:
logger.debug("Skipping tool result content block")
else:
yield event
# Check for ContentBlockStopEvent for a tool use block
if isinstance(event.root, ContentBlockStopEvent):
if tool_result_detected:
logger.debug("Tool result block ended")
tool_result_detected = False
if (
tool_use_detected
and content_block_index is not None
and event.root.index == content_block_index
):
logger.debug(f"Tool use block ended: {current_tool_use_id}")
message_delta = MessageDeltaEvent(
type="message_delta",
delta=MessageDeltaData(stop_reason="tool_use"),
usage=None,
)
yield StreamingEvent(root=message_delta)
message_stop = MessageStopEvent(type="message_stop")
yield StreamingEvent(root=message_stop)
# Register the tool call
if current_tool_use_id and context.claude_session:
tool_call_manager.register_tool_call(
tool_use_id=current_tool_use_id,
session_id=context.claude_session.session_id,
message_id=context.collected_message.id
if context.collected_message
else None,
)
logger.info(
f"Registered tool call {current_tool_use_id} for session {context.claude_session.session_id}"
)
current_tool_use_id = None
tool_use_detected = False
content_block_index = None
break
================================================
FILE: app/processors/claude_ai/tool_result_processor.py
================================================
import uuid
from loguru import logger
from app.processors.base import BaseProcessor
from app.processors.claude_ai import ClaudeAIContext
from app.models.claude import TextContent, ToolResultContent
from app.models.streaming import MessageStartEvent, StreamingEvent
from app.services.tool_call import tool_call_manager
from app.services.session import session_manager
from app.services.event_processing import EventSerializer
event_serializer = EventSerializer()
class ToolResultProcessor(BaseProcessor):
"""Processor that handles tool result messages and resumes paused sessions."""
async def process(self, context: ClaudeAIContext) -> ClaudeAIContext:
"""
Check if the last message is a tool result and handle accordingly.
Requires:
- messages_api_request in context
Produces:
- Resumes paused session if tool result matches
- Sets event_stream from resumed session
- Skips normal request building/sending
"""
if not context.messages_api_request:
logger.warning(
"Skipping ToolResultProcessor due to missing messages_api_request"
)
return context
messages = context.messages_api_request.messages
if not messages:
return context
last_message = messages[-1]
if last_message.role != "user":
return context
if isinstance(last_message.content, str):
return context
# Find tool result content block
lsat_content_block = last_message.content[-1]
if not isinstance(lsat_content_block, ToolResultContent):
return context
tool_result = lsat_content_block
logger.debug(f"Found tool result for tool_use_id: {tool_result.tool_use_id}")
# Check if we have a pending tool call for this ID
tool_call_state = tool_call_manager.get_tool_call(tool_result.tool_use_id)
if not tool_call_state:
logger.debug(
f"No pending tool call found for tool_use_id: {tool_result.tool_use_id}"
)
return context
# Get the session
session = await session_manager.get_session(tool_call_state.session_id)
if not session:
logger.error(
f"Session {tool_call_state.session_id} not found for tool call {tool_result.tool_use_id}"
)
tool_call_manager.complete_tool_call(tool_result.tool_use_id)
return context
if isinstance(tool_result.content, str):
tool_result.content = [TextContent(type="text", text=tool_result.content)]
tool_result_payload = tool_result.model_dump()
await session.send_tool_result(tool_result_payload)
logger.info(
f"Sent tool result for {tool_result.tool_use_id} to session {session.session_id}"
)
if not session.sse_stream:
logger.error(f"No stream available for session {session.session_id}")
tool_call_manager.complete_tool_call(tool_result.tool_use_id)
return context
# Continue with the existing stream
resumed_stream = session.sse_stream
message_start_event = MessageStartEvent(
type="message_start",
message=context.collected_message
if context.collected_message
else {
"id": tool_call_state.message_id or str(uuid.uuid4()),
"type": "message",
"role": "assistant",
"content": [],
"model": context.messages_api_request.model,
},
)
# Create a generator that yields the message start event followed by the resumed stream
async def resumed_event_stream():
yield event_serializer.serialize_event(
StreamingEvent(root=message_start_event)
)
async for event in resumed_stream:
yield event
context.original_stream = resumed_event_stream()
context.claude_session = session
tool_call_manager.complete_tool_call(tool_result.tool_use_id)
# Skip the normal Claude AI processor
context.metadata["skip_processors"] = [
"ClaudeAPIProcessor",
"ClaudeWebProcessor",
]
return context
================================================
FILE: app/processors/pipeline.py
================================================
from typing import List, Optional
from loguru import logger
from app.processors.base import BaseContext, BaseProcessor
class ProcessingPipeline(BaseProcessor):
"""
Main pipeline for processing Claude requests.
"""
def __init__(self, processors: Optional[List[BaseProcessor]] = None):
"""
Initialize the pipeline with processors.
Args:
processors: List of processors to use. If None, default processors are used.
"""
self.processors = processors
logger.debug(f"Initialized pipeline with {len(self.processors)} processors")
for processor in self.processors:
logger.debug(f" - {processor.name}")
async def process(self, context: BaseContext) -> BaseContext:
"""
Process a request through the pipeline.
Args:
context: The processing context
Returns:
Updated context.
"""
logger.debug("Starting pipeline processing")
# Process through each processor
for i, processor in enumerate(self.processors):
if processor.name in context.metadata.get("skip_processors", []):
logger.debug(
f"Skipping processor {processor.name} due to being in skip_processors list"
)
continue
logger.debug(
f"Running processor {i + 1}/{len(self.processors)}: {processor.name}"
)
context = await processor.process(context)
if context.metadata.get("stop_pipeline", False):
logger.debug(f"Pipeline stopped by {processor.name}")
break
logger.debug("Pipeline processing completed successfully")
return context
================================================
FILE: app/services/__init__.py
================================================
================================================
FILE: app/services/account.py
================================================
import asyncio
from datetime import datetime, UTC
from typing import List, Optional, Dict, Set
from collections import defaultdict
from loguru import logger
import threading
import json
import uuid
from app.core.config import settings
from app.core.exceptions import NoAccountsAvailableError
from app.core.account import Account, AccountStatus, AuthType, OAuthToken
from app.services.oauth import oauth_authenticator
class AccountManager:
"""
Singleton manager for Claude.ai accounts with load balancing and rate limit recovery.
Supports both cookie and OAuth authentication.
"""
_instance: Optional["AccountManager"] = None
_lock = threading.Lock()
def __new__(cls):
"""Implement singleton pattern."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize the AccountManager."""
self._accounts: Dict[str, Account] = {} # organization_uuid -> Account
self._cookie_to_uuid: Dict[str, str] = {} # cookie_value -> organization_uuid
self._session_accounts: Dict[str, str] = {} # session_id -> organization_uuid
self._account_sessions: Dict[str, Set[str]] = defaultdict(
set
) # organization_uuid -> set of session_ids
self._account_task: Optional[asyncio.Task] = None
self._max_sessions_per_account = settings.max_sessions_per_cookie
self._account_task_interval = settings.account_task_interval
logger.info("AccountManager initialized")
async def add_account(
self,
cookie_value: Optional[str] = None,
oauth_token: Optional[OAuthToken] = None,
organization_uuid: Optional[str] = None,
capabilities: Optional[List[str]] = None,
) -> Account:
"""Add a new account to the manager.
Args:
cookie_value: The cookie value (optional)
oauth_token: The OAuth token (optional)
organization_uuid: The organization UUID (optional, will be fetched or generated if not provided)
capabilities: The account capabilities (optional)
Raises:
ValueError: If neither cookie_value nor oauth_token is provided
"""
if not cookie_value and not oauth_token:
raise ValueError("Either cookie_value or oauth_token must be provided")
if cookie_value and cookie_value in self._cookie_to_uuid:
return self._accounts[self._cookie_to_uuid[cookie_value]]
if cookie_value and (not organization_uuid or not capabilities):
(
fetched_uuid,
capabilities,
) = await oauth_authenticator.get_organization_info(cookie_value)
if fetched_uuid:
organization_uuid = fetched_uuid
if organization_uuid and organization_uuid in self._accounts:
existing_account = self._accounts[organization_uuid]
if cookie_value and existing_account.cookie_value != cookie_value:
if existing_account.cookie_value:
del self._cookie_to_uuid[existing_account.cookie_value]
existing_account.cookie_value = cookie_value
self._cookie_to_uuid[cookie_value] = organization_uuid
return existing_account
if not organization_uuid:
organization_uuid = str(uuid.uuid4())
logger.info(f"Generated new organization UUID: {organization_uuid}")
# Create new account
if cookie_value and oauth_token:
auth_type = AuthType.BOTH
elif cookie_value:
auth_type = AuthType.COOKIE_ONLY
else:
auth_type = AuthType.OAUTH_ONLY
account = Account(
organization_uuid=organization_uuid,
capabilities=capabilities,
cookie_value=cookie_value,
oauth_token=oauth_token,
auth_type=auth_type,
)
self._accounts[organization_uuid] = account
self.save_accounts()
if cookie_value:
self._cookie_to_uuid[cookie_value] = organization_uuid
logger.info(
f"Added new account: {organization_uuid[:8]}... "
f"(auth_type: {auth_type.value}, "
f"cookie: {cookie_value[:20] + '...' if cookie_value else 'None'}, "
f"oauth: {'Yes' if oauth_token else 'No'})"
)
if auth_type == AuthType.COOKIE_ONLY:
asyncio.create_task(self._attempt_oauth_authentication(account))
return account
async def remove_account(self, organization_uuid: str) -> None:
"""Remove an account from the manager."""
if organization_uuid in self._accounts:
account = self._accounts[organization_uuid]
sessions_to_remove = list(
self._account_sessions.get(organization_uuid, set())
)
for session_id in sessions_to_remove:
if session_id in self._session_accounts:
del self._session_accounts[session_id]
if account.cookie_value and account.cookie_value in self._cookie_to_uuid:
del self._cookie_to_uuid[account.cookie_value]
del self._accounts[organization_uuid]
if organization_uuid in self._account_sessions:
del self._account_sessions[organization_uuid]
logger.info(f"Removed account: {organization_uuid[:8]}...")
self.save_accounts()
async def get_account_for_session(
self,
session_id: str,
is_pro: Optional[bool] = None,
is_max: Optional[bool] = None,
) -> Account:
"""
Get an available account for the session with load balancing.
Args:
session_id: Unique identifier for the session
is_pro: Filter by pro capability. None means any.
is_max: Filter by max capability. None means any.
Returns:
Account instance if available
"""
# Convert single auth_type to list for uniform handling
if session_id in self._session_accounts:
organization_uuid = self._session_accounts[session_id]
if organization_uuid in self._accounts:
account = self._accounts[organization_uuid]
if account.status == AccountStatus.VALID:
return account
else:
del self._session_accounts[session_id]
self._account_sessions[organization_uuid].discard(session_id)
best_account = None
min_sessions = float("inf")
earliest_last_used = None
for organization_uuid, account in self._accounts.items():
if account.status != AccountStatus.VALID:
continue
# Filter by auth type if specified
if account.auth_type not in [AuthType.BOTH, AuthType.COOKIE_ONLY]:
continue
# Filter by capabilities if specified
if is_pro is not None and account.is_pro != is_pro:
continue
if is_max is not None and account.is_max != is_max:
continue
session_count = len(self._account_sessions[organization_uuid])
if session_count >= self._max_sessions_per_account:
continue
# Select account with least sessions
# If multiple accounts have the same least sessions, select the one with earliest last_used
if session_count < min_sessions or (
session_count == min_sessions
and (
earliest_last_used is not None
and account.last_used < earliest_last_used
)
):
min_sessions = session_count
earliest_last_used = account.last_used
best_account = account
if best_account:
self._session_accounts[session_id] = best_account.organization_uuid
self._account_sessions[best_account.organization_uuid].add(session_id)
logger.debug(
f"Assigned account to session {session_id}, "
f"account now has {len(self._account_sessions[best_account.organization_uuid])} sessions"
)
return best_account
raise NoAccountsAvailableError()
async def get_account_for_oauth(
self,
is_pro: Optional[bool] = None,
is_max: Optional[bool] = None,
) -> Account:
"""
Get an available account for OAuth authentication.
Args:
is_pro: Filter by pro capability. None means any.
is_max: Filter by max capability. None means any.
Returns:
Account instance if available
"""
earliest_account = None
earliest_last_used = None
for account in self._accounts.values():
if account.status != AccountStatus.VALID:
continue
if account.auth_type not in [AuthType.OAUTH_ONLY, AuthType.BOTH]:
continue
# Filter by capabilities if specified
if is_pro is not None and account.is_pro != is_pro:
continue
if is_max is not None and account.is_max != is_max:
continue
if earliest_last_used is None or account.last_used < earliest_last_used:
earliest_last_used = account.last_used
earliest_account = account
if earliest_account:
logger.debug(
f"Selected OAuth account: {earliest_account.organization_uuid[:8]}... "
f"(last used: {earliest_account.last_used.isoformat()})"
)
return earliest_account
raise NoAccountsAvailableError()
async def get_account_by_id(self, account_id: str) -> Optional[Account]:
"""
Get an account by its organization UUID.
Args:
account_id: The organization UUID of the account
Returns:
Account instance if found and valid, None otherwise
"""
account = self._accounts.get(account_id)
if account and account.status == AccountStatus.VALID:
logger.debug(f"Retrieved account by ID: {account_id[:8]}...")
return account
if account:
logger.debug(
f"Account {account_id[:8]}... found but not valid: status={account.status}"
)
else:
logger.debug(f"Account {account_id[:8]}... not found")
return None
async def release_session(self, session_id: str) -> None:
"""Release a session's account assignment."""
if session_id in self._session_accounts:
organization_uuid = self._session_accounts[session_id]
del self._session_accounts[session_id]
if organization_uuid in self._account_sessions:
self._account_sessions[organization_uuid].discard(session_id)
logger.debug(f"Released account for session {session_id}")
async def start_task(self) -> None:
"""Start the background task for AccountManager."""
if self._account_task is None or self._account_task.done():
self._account_task = asyncio.create_task(self._task_loop())
async def stop_task(self) -> None:
"""Stop the background task for AccountManager."""
if self._account_task and not self._account_task.done():
self._account_task.cancel()
try:
await self._account_task
except asyncio.CancelledError:
pass
async def _task_loop(self) -> None:
"""Background loop for AccountManager."""
while True:
try:
await self._check_and_recover_accounts()
await self._check_and_refresh_accounts()
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in task loop: {e}")
finally:
await asyncio.sleep(self._account_task_interval)
async def _check_and_recover_accounts(self) -> None:
"""Check and recover rate-limited accounts."""
current_time = datetime.now(UTC)
for account in self._accounts.values():
# Check rate-limited accounts
if (
account.status == AccountStatus.RATE_LIMITED
and account.resets_at
and current_time >= account.resets_at
):
account.status = AccountStatus.VALID
account.resets_at = None
logger.info(
f"Recovered rate-limited account: {account.organization_uuid[:8]}..."
)
async def _check_and_refresh_accounts(self) -> None:
"""Check and refresh expired/expiring tokens."""
current_timestamp = datetime.now(UTC).timestamp()
for account in self._accounts.values():
if (
account.auth_type in [AuthType.OAUTH_ONLY, AuthType.BOTH]
and account.oauth_token
and account.oauth_token.refresh_token
and account.oauth_token.expires_at
):
if account.oauth_token.expires_at - current_timestamp < 300:
asyncio.create_task(self._refresh_account_token(account))
async def _refresh_account_token(self, account: Account) -> None:
"""Refresh OAuth token for an account."""
logger.info(
f"Refreshing OAuth token for account: {account.organization_uuid[:8]}..."
)
success = await oauth_authenticator.refresh_account_token(account)
if success:
logger.info(
f"Successfully refreshed OAuth token for account: {account.organization_uuid[:8]}..."
)
else:
logger.warning(
f"Failed to refresh OAuth token for account: {account.organization_uuid[:8]}..."
)
if account.auth_type == AuthType.BOTH:
account.auth_type = AuthType.COOKIE_ONLY
account.oauth_token = None
else:
account.status = AccountStatus.INVALID
logger.error(
f"Account {account.organization_uuid[:8]} is now invalid due to OAuth refresh failure"
)
self.save_accounts()
async def _attempt_oauth_authentication(self, account: Account) -> None:
"""Attempt OAuth authentication for an account."""
logger.info(
f"Attempting OAuth authentication for account: {account.organization_uuid[:8]}..."
)
success = await oauth_authenticator.authenticate_account(account)
if not success:
logger.warning(
f"OAuth authentication failed for account: {account.organization_uuid[:8]}..., keeping as CookieOnly"
)
else:
logger.info(
f"OAuth authentication successful for account: {account.organization_uuid[:8]}..."
)
async def get_status(self) -> Dict:
"""Get the current status of all accounts."""
status = {
"total_accounts": len(self._accounts),
"valid_accounts": sum(
1 for a in self._accounts.values() if a.status == AccountStatus.VALID
),
"rate_limited_accounts": sum(
1
for a in self._accounts.values()
if a.status == AccountStatus.RATE_LIMITED
),
"invalid_accounts": sum(
1 for a in self._accounts.values() if a.status == AccountStatus.INVALID
),
"active_sessions": len(self._session_accounts),
"accounts": [],
}
for organization_uuid, account in self._accounts.items():
account_info = {
"organization_uuid": organization_uuid[:8] + "...",
"cookie": account.cookie_value[:20] + "..."
if account.cookie_value
else "None",
"status": account.status.value,
"auth_type": account.auth_type.value,
"sessions": len(self._account_sessions[organization_uuid]),
"last_used": account.last_used.isoformat(),
"resets_at": account.resets_at.isoformat()
if account.resets_at
else None,
"has_oauth": account.oauth_token is not None,
}
status["accounts"].append(account_info)
return status
def save_accounts(self) -> None:
"""Save all accounts to JSON file.
Args:
data_folder: Optional data folder path. If not provided, uses settings.data_folder
"""
if settings.no_filesystem_mode:
logger.debug("No-filesystem mode enabled, skipping account save to disk")
return
settings.data_folder.mkdir(parents=True, exist_ok=True)
accounts_file = settings.data_folder / "accounts.json"
accounts_data = {
organization_uuid: account.to_dict()
for organization_uuid, account in self._accounts.items()
}
with open(accounts_file, "w", encoding="utf-8") as f:
json.dump(accounts_data, f, indent=2)
logger.info(f"Saved {len(accounts_data)} accounts to {accounts_file}")
def load_accounts(self) -> None:
"""Load accounts from JSON file.
Args:
data_folder: Optional data folder path. If not provided, uses settings.data_folder
"""
if settings.no_filesystem_mode:
logger.debug("No-filesystem mode enabled, skipping account load from disk")
return
accounts_file = settings.data_folder / "accounts.json"
if not accounts_file.exists():
logger.info(f"No accounts file found at {accounts_file}")
return
try:
with open(accounts_file, "r", encoding="utf-8") as f:
accounts_data = json.load(f)
for organization_uuid, account_data in accounts_data.items():
account = Account.from_dict(account_data)
self._accounts[organization_uuid] = account
# Rebuild cookie mapping
if account.cookie_value:
self._cookie_to_uuid[account.cookie_value] = organization_uuid
logger.info(f"Loaded {len(accounts_data)} accounts from {accounts_file}")
except Exception as e:
logger.error(f"Failed to load accounts from {accounts_file}: {e}")
def __repr__(self) -> str:
"""String representation of the AccountManager."""
return f""
account_manager = AccountManager()
================================================
FILE: app/services/cache.py
================================================
import asyncio
import hashlib
import json
import threading
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from loguru import logger
from app.core.config import settings
from app.models.claude import (
Base64ImageSource,
FileImageSource,
ImageContent,
InputMessage,
ContentBlock,
ServerToolUseContent,
TextContent,
ThinkingContent,
ToolResultContent,
ToolUseContent,
URLImageSource,
WebSearchToolResultContent,
)
class CacheCheckpoint:
"""Cache checkpoint with timestamp."""
def __init__(self, checkpoint: str, account_id: str):
self.checkpoint = checkpoint
self.account_id = account_id
self.created_at = datetime.now()
class CacheService:
"""
Service for managing prompt cache mapping to accounts.
Ensures requests with cached prompts are sent to the same account.
"""
_instance: Optional["CacheService"] = None
_lock = threading.Lock()
def __new__(cls):
"""Implement singleton pattern."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize the CacheService."""
# Maps checkpoint hash -> CacheCheckpoint
self._checkpoints: Dict[str, CacheCheckpoint] = {}
self._cleanup_task: Optional[asyncio.Task] = None
logger.info(
f"CacheService initialized with timeout={settings.cache_timeout}s, "
f"cleanup_interval={settings.cache_cleanup_interval}s"
)
def process_messages(
self,
model: str,
messages: List[InputMessage],
system: Optional[List[TextContent]] = None,
) -> Tuple[Optional[str], List[str]]:
"""
Process messages to find cached account and extract new checkpoints.
Args:
messages: List of input messages
system: Optional system messages
Returns:
Tuple of (account_id, checkpoints) where:
- account_id: The account ID if a cached prompt was found, None otherwise
- checkpoints: List of feature values for content blocks with cache_control
"""
account_id: Optional[str] = None
checkpoints: List[str] = []
hasher = hashlib.sha256()
self._update_hasher(hasher, {"model": model})
if system:
for text_content in system:
content_block_data = self._content_block_to_dict(text_content)
self._update_hasher(hasher, content_block_data)
feature_value = hasher.hexdigest()
if text_content.cache_control:
checkpoints.append(feature_value)
if feature_value in self._checkpoints:
account_id = self._checkpoints[feature_value].account_id
for message in messages:
self._update_hasher(hasher, {"role": message.role.value})
if isinstance(message.content, str):
self._update_hasher(hasher, {"type": "text", "text": message.content})
elif isinstance(message.content, list):
for content_block in message.content:
content_block_data = self._content_block_to_dict(content_block)
self._update_hasher(hasher, content_block_data)
feature_value = hasher.hexdigest()
if (
hasattr(content_block, "cache_control")
and content_block.cache_control
):
checkpoints.append(feature_value)
if feature_value in self._checkpoints:
account_id = self._checkpoints[feature_value].account_id
if account_id:
logger.debug(
f"Cache hit: account_id={account_id}, feature={feature_value[:16]}..."
)
return account_id, checkpoints
def add_checkpoints(self, checkpoints: List[str], account_id: str) -> None:
"""
Add checkpoint mappings to the cache.
Args:
checkpoints: List of feature values to map
account_id: Account ID to map to
"""
for checkpoint in checkpoints:
self._checkpoints[checkpoint] = CacheCheckpoint(checkpoint, account_id)
logger.debug(
f"Added checkpoint mapping: {checkpoint[:16]}... -> {account_id}"
)
logger.debug(
f"Cache updated: {len(checkpoints)} checkpoints added. "
f"Total cache size: {len(self._checkpoints)}"
)
def _update_hasher(self, hasher: "hashlib._Hash", data: Dict) -> None:
"""
Update the hasher with new data in a consistent way.
Args:
hasher: The hash object to update
data: Dictionary data to add to the hash
"""
# Serialize data in a consistent way
json_str = json.dumps(data, sort_keys=True, separators=(",", ":"))
# Add a delimiter to ensure proper separation between blocks
hasher.update(b"\x00") # NULL byte as delimiter
hasher.update(json_str.encode("utf-8"))
def _content_block_to_dict(self, content_block: ContentBlock) -> Dict:
"""
Convert a ContentBlock to a dictionary for hashing.
Only includes relevant fields for cache matching.
"""
result = {"type": content_block.type}
if isinstance(content_block, TextContent):
result["text"] = content_block.text
elif isinstance(content_block, ThinkingContent):
result["thinking"] = content_block.thinking
elif isinstance(content_block, ToolUseContent) or isinstance(
content_block, ServerToolUseContent
):
result["id"] = content_block.id
elif isinstance(content_block, ToolResultContent) or isinstance(
content_block, WebSearchToolResultContent
):
result["tool_use_id"] = content_block.tool_use_id
elif isinstance(content_block, ImageContent):
result["source_type"] = content_block.source.type
if isinstance(content_block.source, Base64ImageSource):
result["source_data"] = content_block.source.data
elif isinstance(content_block.source, URLImageSource):
result["source_url"] = content_block.source.url
elif isinstance(content_block.source, FileImageSource):
result["source_file"] = content_block.source.file_uuid
return result
async def start_cleanup_task(self) -> None:
"""Start the background task for cleaning up expired cache checkpoints."""
if self._cleanup_task is None or self._cleanup_task.done():
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info("Started cache cleanup task")
async def stop_cleanup_task(self) -> None:
"""Stop the background cleanup task."""
if self._cleanup_task and not self._cleanup_task.done():
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("Stopped cache cleanup task")
async def _cleanup_loop(self) -> None:
"""Background loop to clean up expired cache checkpoints."""
while True:
try:
self._cleanup_expired_checkpoints()
await asyncio.sleep(settings.cache_cleanup_interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in cache cleanup loop: {e}")
await asyncio.sleep(settings.cache_cleanup_interval)
def _cleanup_expired_checkpoints(self) -> None:
"""Clean up all expired cache checkpoints."""
current_time = datetime.now()
timeout_duration = timedelta(seconds=settings.cache_timeout)
expired_checkpoints = []
for checkpoint_hash, cache_checkpoint in self._checkpoints.items():
if (current_time - cache_checkpoint.created_at) > timeout_duration:
expired_checkpoints.append(checkpoint_hash)
for checkpoint_hash in expired_checkpoints:
del self._checkpoints[checkpoint_hash]
if expired_checkpoints:
logger.info(
f"Cleaned up {len(expired_checkpoints)} expired cache checkpoints"
)
async def cleanup_all(self) -> None:
"""Clean up all cache checkpoints and stop the cleanup task."""
await self.stop_cleanup_task()
self._checkpoints.clear()
logger.info("Cleaned up all cache checkpoints")
def __repr__(self) -> str:
"""String representation of the CacheService."""
return f""
cache_service = CacheService()
================================================
FILE: app/services/event_processing/__init__.py
================================================
from .event_parser import EventParser
from .event_serializer import EventSerializer
__all__ = [
"EventParser",
"EventSerializer",
]
================================================
FILE: app/services/event_processing/event_parser.py
================================================
import json
from typing import AsyncIterator, Optional
from dataclasses import dataclass
from loguru import logger
from pydantic import ValidationError
from app.models.streaming import (
StreamingEvent,
UnknownEvent,
)
@dataclass
class SSEMessage:
event: Optional[str] = None
data: Optional[str] = None
class EventParser:
"""Parses SSE (Server-Sent Events) streams into StreamingEvent objects."""
def __init__(self, skip_unknown_events: bool = True):
self.skip_unknown_events = skip_unknown_events
self.buffer = ""
async def parse_stream(
self, stream: AsyncIterator[str]
) -> AsyncIterator[StreamingEvent]:
"""
Parse an SSE stream and yield StreamingEvent objects.
Args:
stream: AsyncIterator that yields string chunks from the SSE stream
Yields:
StreamingEvent objects parsed from the stream
"""
async for chunk in stream:
chunk = chunk.replace('\r\n', '\n') # Normalize line endings
self.buffer += chunk
async for event in self._process_buffer():
logger.debug(f"Parsed event:\n{event.model_dump()}")
yield event
async for event in self.flush():
yield event
async def _process_buffer(self) -> AsyncIterator[StreamingEvent]:
"""Process the buffer and yield complete SSE messages as StreamingEvent objects."""
while "\n\n" in self.buffer:
message_end = self.buffer.index("\n\n")
message_text = self.buffer[:message_end]
self.buffer = self.buffer[message_end + 2 :]
sse_msg = self._parse_sse_message(message_text)
if sse_msg.data:
event = self._create_streaming_event(sse_msg)
if event:
yield event
def _parse_sse_message(self, message_text: str) -> SSEMessage:
"""Parse a single SSE message from text."""
sse_msg = SSEMessage()
for line in message_text.split("\n"):
if not line:
continue
if ":" not in line:
field = line
value = ""
else:
field, value = line.split(":", 1)
if value.startswith(" "):
value = value[1:]
if field == "event":
sse_msg.event = value
elif field == "data":
if sse_msg.data is None:
sse_msg.data = value
else:
sse_msg.data += "\n" + value
return sse_msg
def _create_streaming_event(self, sse_msg: SSEMessage) -> Optional[StreamingEvent]:
"""
Create a StreamingEvent from an SSE message.
Args:
sse_msg: The parsed SSE message
Returns:
StreamingEvent object or None if parsing fails
"""
try:
data = json.loads(sse_msg.data)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON data: {e}")
logger.debug(f"Raw data: {sse_msg.data}")
return None
try:
streaming_event = StreamingEvent(root=data)
except ValidationError:
if self.skip_unknown_events:
logger.debug(f"Skipping unknown event: {sse_msg.event}")
return None
logger.warning(
"Failed to validate streaming event. Falling back to UnknownEvent."
)
logger.debug(f"Event data: {data}")
streaming_event = StreamingEvent(
root=UnknownEvent(type=sse_msg.event, data=data)
)
return streaming_event
async def flush(self) -> AsyncIterator[StreamingEvent]:
"""
Flush any remaining data in the buffer.
This should be called when the stream ends to process any incomplete messages.
Yields:
Any remaining StreamingEvent objects
"""
if self.buffer.strip():
logger.warning(f"Flushing incomplete buffer: {self.buffer[:100]}...")
self.buffer += "\n\n"
async for event in self._process_buffer():
yield event
================================================
FILE: app/services/event_processing/event_serializer.py
================================================
import json
from typing import AsyncIterator, Optional
from app.models.streaming import StreamingEvent, UnknownEvent
class EventSerializer:
"""Serializes StreamingEvent objects into SSE (Server-Sent Events) format."""
def __init__(self, skip_unknown_events: bool = True):
self.skip_unknown_events = skip_unknown_events
async def serialize_stream(
self, events: AsyncIterator[StreamingEvent]
) -> AsyncIterator[str]:
"""
Serialize a stream of StreamingEvent objects into SSE format.
Args:
events: AsyncIterator that yields StreamingEvent objects
Yields:
String chunks in SSE format
"""
async for event in events:
sse_message = self.serialize_event(event)
if sse_message:
yield sse_message
def serialize_event(self, event: StreamingEvent) -> Optional[str]:
"""
Serialize a single StreamingEvent into SSE format.
Args:
event: StreamingEvent object to serialize
Returns:
SSE formatted string or None if serialization fails
"""
if isinstance(event.root, UnknownEvent):
if self.skip_unknown_events:
return None
json_data = json.dumps(
event.root.data, ensure_ascii=False, separators=(",", ":")
)
else:
json_data = event.model_dump_json(exclude_none=True)
sse_parts = []
if event.root.type:
sse_parts.append(f"event: {event.root.type}")
data_lines = json_data.split("\n")
for line in data_lines:
sse_parts.append(f"data: {line}")
sse_message = "\n".join(sse_parts) + "\n\n"
return sse_message
async def serialize_batch(self, events: list[StreamingEvent]) -> str:
"""
Serialize a batch of StreamingEvent objects into a single SSE string.
Args:
events: List of StreamingEvent objects to serialize
Returns:
Concatenated SSE formatted string
"""
result_parts = []
for event in events:
sse_message = self.serialize_event(event)
if sse_message:
result_parts.append(sse_message)
return "".join(result_parts)
================================================
FILE: app/services/i18n.py
================================================
import json
import re
from typing import Dict, Any, Optional
from loguru import logger
from app.core.config import settings
class I18nService:
"""
Internationalization service for loading and managing translations.
Supports message interpolation with context variables.
"""
def __init__(self):
self._translations: Dict[str, Dict[str, Any]] = {}
self._default_language = settings.default_language
self._locales_dir = settings.locales_folder
self._load_translations()
def _load_translations(self) -> None:
"""Load all translation files from the locales directory."""
if not self._locales_dir.exists():
logger.warning(f"Locales directory not found: {self._locales_dir}")
return
for file_path in self._locales_dir.glob("*.json"):
language_code = file_path.stem
try:
with open(file_path, "r", encoding="utf-8") as f:
self._translations[language_code] = json.load(f)
logger.info(f"Loaded translations for language: {language_code}")
except Exception as e:
logger.error(f"Failed to load translations for {language_code}: {e}")
def _get_nested_value(self, data: Dict[str, Any], key: str) -> Optional[str]:
"""
Get a nested value from a dictionary using dot notation.
Example: 'global.internalServerError' -> data['global']['internalServerError']
"""
keys = key.split(".")
current = data
for k in keys:
if isinstance(current, dict) and k in current:
current = current[k]
else:
return None
return current if isinstance(current, str) else None
def _interpolate_message(self, message: str, context: Dict[str, Any]) -> str:
"""
Interpolate context variables into the message.
Supports {variable_name} syntax.
"""
if not context:
return message
# Use regex to find all {variable_name} patterns and replace them
def replace_var(match):
var_name = match.group(1)
return str(context.get(var_name, match.group(0)))
return re.sub(r"\{([^}]+)\}", replace_var, message)
def get_message(
self,
message_key: str,
language: str = None,
context: Optional[Dict[str, Any]] = None,
) -> str:
"""
Get a translated message by key and language.
Args:
message_key: The message key in dot notation (e.g., 'global.internalServerError')
language: The language code (defaults to default language)
context: Context variables for message interpolation
Returns:
The translated and interpolated message
"""
if language is None:
language = self._default_language
if language in self._translations:
message = self._get_nested_value(self._translations[language], message_key)
if message:
return self._interpolate_message(message, context or {})
if (
language != self._default_language
and self._default_language in self._translations
):
message = self._get_nested_value(
self._translations[self._default_language], message_key
)
if message:
return self._interpolate_message(message, context or {})
logger.warning(
f"Translation not found for key '{message_key}' in language '{language}'"
)
return message_key
def parse_accept_language(self, accept_language: Optional[str]) -> str:
"""
Parse Accept-Language header and return the best matching language.
Args:
accept_language: The Accept-Language header value
Returns:
The best matching language code
"""
if not accept_language:
return self._default_language
languages = []
for lang_part in accept_language.split(","):
lang_part = lang_part.strip()
if ";" in lang_part:
lang, quality = lang_part.split(";", 1)
try:
q = float(quality.split("=")[1])
except (IndexError, ValueError):
q = 1.0
else:
lang = lang_part
q = 1.0
primary_lang = lang.split("-")[0].lower()
languages.append((primary_lang, q))
languages.sort(key=lambda x: x[1], reverse=True)
for lang, _ in languages:
if lang in self._translations:
return lang
return self._default_language
def get_supported_languages(self) -> list[str]:
"""Get list of supported language codes."""
return list(self._translations.keys())
def reload_translations(self) -> None:
"""Reload all translation files."""
self._translations.clear()
self._load_translations()
i18n_service = I18nService()
================================================
FILE: app/services/oauth.py
================================================
import base64
import hashlib
import secrets
import time
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse, parse_qs
from app.core.http_client import Response, create_session, create_plain_session
from loguru import logger
from app.core.config import settings
from app.core.account import Account, AuthType, OAuthToken
from app.core.exceptions import (
AppError,
ClaudeAuthenticationError,
ClaudeHttpError,
CloudflareBlockedError,
CookieAuthorizationError,
OAuthExchangeError,
OrganizationInfoError,
)
class OAuthAuthenticator:
"""OAuth authenticator for Claude accounts using cookies."""
def _generate_pkce(self) -> Tuple[str, str]:
"""Generate PKCE verifier and challenge."""
verifier = (
base64.urlsafe_b64encode(secrets.token_bytes(32))
.decode("utf-8")
.rstrip("=")
)
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("utf-8")).digest())
.decode("utf-8")
.rstrip("=")
)
return verifier, challenge
def _build_headers(self, cookie: str) -> Dict[str, str]:
"""Build request headers."""
claude_endpoint = settings.claude_ai_url.encoded_string().rstrip("/")
return {
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Cache-Control": "no-cache",
"Cookie": cookie,
"Origin": claude_endpoint,
"Referer": f"{claude_endpoint}/new",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
}
async def _request(self, method: str, url: str, **kwargs) -> Response:
"""Browser-impersonating request — for claude.ai endpoints (Cloudflare)."""
session = create_session(
timeout=settings.request_timeout,
impersonate="chrome",
proxy=settings.proxy_url,
follow_redirects=False,
)
async with session:
response: Response = await session.request(method=method, url=url, **kwargs)
if response.status_code == 302:
raise CloudflareBlockedError()
if response.status_code == 403:
raise ClaudeAuthenticationError()
if response.status_code >= 300:
raise ClaudeHttpError(
url=url,
status_code=response.status_code,
error_type="Unknown",
error_message="Error occurred during request to Claude.ai",
)
return response
async def _token_request(self, url: str, data: dict) -> Response:
"""Plain (non-impersonating) POST to the OAuth token endpoint.
console.anthropic.com/v1/oauth/token rejects requests that carry
browser fingerprinting headers (User-Agent, Origin, TLS JA3).
Using httpx here avoids the 429.
"""
session = create_plain_session(
timeout=settings.request_timeout,
proxy=settings.proxy_url,
follow_redirects=False,
)
async with session:
response: Response = await session.request(
method="POST",
url=url,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "claude-cli/2.1.81 (external, cli)",
},
)
if response.status_code != 200:
try:
error_body = await response.json()
except Exception:
error_body = ""
logger.error(
f"Token endpoint returned {response.status_code}: {error_body}"
)
return response
async def get_organization_info(self, cookie: str) -> Tuple[str, List[str]]:
"""Get organization UUID and capabilities."""
url = f"{settings.claude_ai_url.encoded_string().rstrip('/')}/api/organizations"
headers = self._build_headers(cookie)
try:
response = await self._request("GET", url, headers=headers)
org_data = await response.json()
if org_data and isinstance(org_data, list):
organization_uuid = None
max_capabilities = []
for org in org_data:
if "uuid" in org and "capabilities" in org:
capabilities = org.get("capabilities", [])
if "chat" not in capabilities:
continue
if len(capabilities) > len(max_capabilities):
organization_uuid = org.get("uuid")
max_capabilities = capabilities
if organization_uuid:
logger.info(
f"Found organization UUID: {organization_uuid}, capabilities: {max_capabilities}"
)
return organization_uuid, max_capabilities
raise OrganizationInfoError(
reason="No valid organization found with chat capabilities"
)
else:
logger.error("No organization data found in response")
raise OrganizationInfoError(reason="No organization data found")
except AppError as e:
raise e
except Exception as e:
logger.error(f"Error getting organization UUID: {e}")
raise OrganizationInfoError(reason=str(e))
async def authorize_with_cookie(
self, cookie: str, organization_uuid: str
) -> Tuple[str, str]:
"""
Use Cookie to automatically get authorization code.
Returns: (authorization code, verifier)
"""
verifier, challenge = self._generate_pkce()
state = (
base64.urlsafe_b64encode(secrets.token_bytes(32))
.decode("utf-8")
.rstrip("=")
)
authorize_url = settings.oauth_authorize_url.format(
organization_uuid=organization_uuid
)
payload = {
"response_type": "code",
"client_id": settings.oauth_client_id,
"organization_uuid": organization_uuid,
"redirect_uri": settings.oauth_redirect_uri,
"scope": "user:profile user:inference",
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
headers = self._build_headers(cookie)
headers["Content-Type"] = "application/json"
logger.debug(f"Requesting authorization from: {authorize_url}")
response = await self._request(
"POST", authorize_url, json=payload, headers=headers
)
auth_response = await response.json()
redirect_uri = auth_response.get("redirect_uri")
if not redirect_uri:
logger.error("No redirect_uri in authorization response")
raise CookieAuthorizationError(reason="No redirect URI found in response")
logger.info(f"Got redirect URI: {redirect_uri}")
parsed_url = urlparse(redirect_uri)
query_params = parse_qs(parsed_url.query)
if "code" not in query_params:
logger.error("No authorization code in redirect_uri")
raise CookieAuthorizationError(
reason="No authorization code found in response"
)
auth_code = query_params["code"][0]
response_state = query_params.get("state", [None])[0]
logger.info(f"Extracted authorization code: {auth_code[:20]}...")
if response_state:
full_code = f"{auth_code}#{response_state}"
else:
full_code = auth_code
return full_code, verifier
async def exchange_token(self, code: str, verifier: str) -> Dict:
"""Exchange authorization code for access token."""
parts = code.split("#")
auth_code = parts[0]
state = parts[1] if len(parts) > 1 else None
data = {
"code": auth_code,
"grant_type": "authorization_code",
"client_id": settings.oauth_client_id,
"redirect_uri": settings.oauth_redirect_uri,
"code_verifier": verifier,
}
if state:
data["state"] = state
try:
response = await self._token_request(settings.oauth_token_url, data)
token_data = await response.json()
if (
"access_token" not in token_data
or "refresh_token" not in token_data
or "expires_in" not in token_data
):
logger.error("Invalid token response received")
raise OAuthExchangeError(reason="Invalid token response")
return token_data
except AppError as e:
raise e
except Exception as e:
logger.error(f"Error exchanging token: {e}")
raise OAuthExchangeError(reason=str(e))
async def refresh_access_token(self, refresh_token: str) -> Optional[Dict]:
"""Refresh access token."""
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": settings.oauth_client_id,
}
try:
response = await self._token_request(settings.oauth_token_url, data)
if response.status_code != 200:
logger.error(f"Token refresh failed: {response.status_code}")
return None
token_data = await response.json()
return token_data
except Exception as e:
logger.error(f"Error refreshing token: {e}")
return None
async def authenticate_account(self, account: Account) -> bool:
"""
Authenticate an account using OAuth.
Returns True if successful, False otherwise.
"""
if not account.cookie_value:
logger.error("Account has no cookie value")
return False
try:
# Get organization UUID
org_uuid, _ = await self.get_organization_info(account.cookie_value)
# Get authorization code
auth_result = await self.authorize_with_cookie(
account.cookie_value, org_uuid
)
auth_code, verifier = auth_result
# Exchange for tokens
token_data = await self.exchange_token(auth_code, verifier)
# Update account with OAuth tokens
account.oauth_token = OAuthToken(
access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"],
expires_at=time.time() + token_data["expires_in"],
)
account.auth_type = AuthType.BOTH
account.save()
logger.info(
f"Successfully authenticated account with OAuth: {account.organization_uuid[:8]}..."
)
return True
except Exception as e:
logger.error(f"OAuth authentication failed: {e}")
return False
async def refresh_account_token(self, account: Account) -> bool:
"""
Refresh OAuth token for an account.
Returns True if successful, False otherwise.
"""
if not account.oauth_token or not account.oauth_token.refresh_token:
logger.error("Account has no refresh token")
return False
token_data = await self.refresh_access_token(account.oauth_token.refresh_token)
if not token_data:
return False
account.oauth_token = OAuthToken(
access_token=token_data["access_token"],
refresh_token=token_data["refresh_token"],
expires_at=time.time() + token_data["expires_in"],
)
account.save()
logger.info(
f"Successfully refreshed OAuth token for account: {account.organization_uuid[:8]}..."
)
return True
oauth_authenticator = OAuthAuthenticator()
================================================
FILE: app/services/session.py
================================================
import asyncio
from typing import Dict, Optional
from datetime import datetime, timedelta
import threading
from loguru import logger
from app.core.config import settings
from app.core.claude_session import ClaudeWebSession
class SessionManager:
"""
Singleton manager for Claude sessions with automatic cleanup.
"""
_instance: Optional["SessionManager"] = None
_lock = threading.Lock()
def __new__(cls):
"""Implement singleton pattern."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize the SessionManager."""
self._sessions: Dict[str, ClaudeWebSession] = {}
self._session_lock = asyncio.Lock()
self._cleanup_task: Optional[asyncio.Task] = None
self._session_timeout = settings.session_timeout
self._cleanup_interval = settings.session_cleanup_interval
logger.info(
f"SessionManager initialized with timeout={self._session_timeout}s, "
f"cleanup_interval={self._cleanup_interval}s"
)
async def get_or_create_session(self, session_id: str) -> ClaudeWebSession:
"""
Get or create a new Claude session.
Args:
session_id: Unique identifier for the session
Returns:
Created ClaudeSession instance
"""
async with self._session_lock:
if session_id in self._sessions:
return self._sessions[session_id]
session = ClaudeWebSession(session_id)
await session.initialize()
self._sessions[session_id] = session
logger.debug(f"Created new session: {session_id}")
return session
async def get_session(self, session_id: str) -> Optional[ClaudeWebSession]:
"""
Get a session by ID.
Args:
session_id: Unique identifier for the session
Returns:
ClaudeSession instance if found, None otherwise
"""
async with self._session_lock:
session = self._sessions.get(session_id)
if session:
# Check if session is expired
if await self._is_session_expired(session):
logger.debug(f"Session {session_id} is expired, removing")
await self._remove_session(session_id)
return None
return session
async def remove_session(self, session_id: str) -> None:
"""
Remove a session by ID.
Args:
session_id: Unique identifier for the session
"""
async with self._session_lock:
if session_id in self._sessions:
await self._remove_session(session_id)
async def _is_session_expired(self, session: ClaudeWebSession) -> bool:
"""
Check if a session is expired.
A session is considered expired if its last_activity is older than session_timeout.
Args:
session: Session to check
Returns:
True if session is expired, False otherwise
"""
current_time = datetime.now()
timeout_duration = timedelta(seconds=self._session_timeout)
return (current_time - session.last_activity) > timeout_duration
async def _remove_session(self, session_id: str) -> None:
"""
Remove a session and cleanup its resources.
Note: This method should be called while holding the session lock.
Args:
session_id: ID of the session to remove
"""
if session_id in self._sessions:
session = self._sessions[session_id]
asyncio.create_task(session.cleanup()) # Cleanup session asynchronously
# Remove from sessions dict (should already have the lock)
if session_id in self._sessions:
del self._sessions[session_id]
logger.debug(f"Removed session: {session_id}")
async def start_cleanup_task(self) -> None:
"""Start the background task for cleaning up expired sessions."""
if self._cleanup_task is None or self._cleanup_task.done():
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info("Started session cleanup task")
async def stop_cleanup_task(self) -> None:
"""Stop the background cleanup task."""
if self._cleanup_task and not self._cleanup_task.done():
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("Stopped session cleanup task")
async def _cleanup_loop(self) -> None:
"""Background loop to clean up expired sessions."""
while True:
try:
await self._cleanup_expired_sessions()
await asyncio.sleep(self._cleanup_interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in cleanup loop: {e}")
await asyncio.sleep(self._cleanup_interval)
async def _cleanup_expired_sessions(self) -> None:
"""Clean up all expired sessions."""
async with self._session_lock:
expired_sessions = []
for session_id, session in self._sessions.items():
if await self._is_session_expired(session):
expired_sessions.append(session_id)
for session_id in expired_sessions:
await self._remove_session(session_id)
if expired_sessions:
logger.info(f"Cleaned up {len(expired_sessions)} expired sessions")
async def cleanup_all(self) -> None:
"""Clean up all sessions and stop the cleanup task."""
await self.stop_cleanup_task()
async with self._session_lock:
session_ids = list(self._sessions.keys())
for session_id in session_ids:
await self._remove_session(session_id)
logger.info("Cleaned up all sessions")
def __repr__(self) -> str:
"""String representation of the SessionManager."""
return f""
session_manager = SessionManager()
================================================
FILE: app/services/tool_call.py
================================================
import asyncio
from typing import Dict, Optional
from datetime import datetime, timedelta
import threading
from loguru import logger
from app.core.config import settings
class ToolCallState:
"""State for a pending tool call."""
def __init__(self, tool_use_id: str, session_id: str):
self.tool_use_id = tool_use_id
self.session_id = session_id
self.created_at = datetime.now()
self.message_id: Optional[str] = None
class ToolCallManager:
"""
Singleton manager for tool call states.
"""
_instance: Optional["ToolCallManager"] = None
_lock = threading.Lock()
def __new__(cls):
"""Implement singleton pattern."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialize the ToolCallManager."""
self._tool_calls: Dict[str, ToolCallState] = {}
self._cleanup_task: Optional[asyncio.Task] = None
self._tool_call_timeout = settings.tool_call_timeout
self._cleanup_interval = settings.tool_call_cleanup_interval
logger.info(
f"ToolCallManager initialized with timeout={self._tool_call_timeout}s, "
f"cleanup_interval={self._cleanup_interval}s"
)
def register_tool_call(
self, tool_use_id: str, session_id: str, message_id: Optional[str] = None
) -> None:
"""
Register a new tool call.
Args:
tool_use_id: Unique identifier for the tool use
session_id: Session ID associated with this tool call
message_id: Optional message ID for tracking
"""
tool_call_state = ToolCallState(tool_use_id, session_id)
tool_call_state.message_id = message_id
self._tool_calls[tool_use_id] = tool_call_state
logger.info(f"Registered tool call: {tool_use_id} for session: {session_id}")
def get_tool_call(self, tool_use_id: str) -> Optional[ToolCallState]:
"""
Get a tool call state by ID.
Args:
tool_use_id: Tool use ID to lookup
Returns:
ToolCallState if found, None otherwise
"""
return self._tool_calls.get(tool_use_id)
def complete_tool_call(self, tool_use_id: str) -> None:
"""
Mark a tool call as completed and return the associated session ID.
Args:
tool_use_id: Tool use ID to complete
"""
tool_call = self._tool_calls.get(tool_use_id)
if tool_call:
del self._tool_calls[tool_use_id]
logger.info(f"Completed tool call: {tool_use_id}")
async def start_cleanup_task(self) -> None:
"""Start the background task for cleaning up expired tool calls."""
if self._cleanup_task is None or self._cleanup_task.done():
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
logger.info("Started tool call cleanup task")
async def stop_cleanup_task(self) -> None:
"""Stop the background cleanup task."""
if self._cleanup_task and not self._cleanup_task.done():
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
logger.info("Stopped tool call cleanup task")
async def _cleanup_loop(self) -> None:
"""Background loop to clean up expired tool calls."""
while True:
try:
self._cleanup_expired_tool_calls()
await asyncio.sleep(self._cleanup_interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in tool call cleanup loop: {e}")
await asyncio.sleep(self._cleanup_interval)
def _cleanup_expired_tool_calls(self) -> None:
"""Clean up all expired tool calls."""
current_time = datetime.now()
timeout_duration = timedelta(seconds=self._tool_call_timeout)
expired_tool_calls = []
for tool_use_id, tool_call in self._tool_calls.items():
if (current_time - tool_call.created_at) > timeout_duration:
expired_tool_calls.append(tool_use_id)
for tool_use_id in expired_tool_calls:
tool_call = self._tool_calls[tool_use_id]
del self._tool_calls[tool_use_id]
if expired_tool_calls:
logger.info(f"Cleaned up {len(expired_tool_calls)} expired tool calls")
async def cleanup_all(self) -> None:
"""Clean up all tool calls and stop the cleanup task."""
await self.stop_cleanup_task()
self._tool_calls.clear()
logger.info("Cleaned up all tool calls")
def __repr__(self) -> str:
"""String representation of the ToolCallManager."""
return f""
tool_call_manager = ToolCallManager()
================================================
FILE: app/utils/__init__.py
================================================
================================================
FILE: app/utils/logger.py
================================================
import sys
from pathlib import Path
from loguru import logger
from app.core.config import settings
def configure_logger():
"""Initialize the logger with console and optional file output."""
logger.remove()
logger.add(
sys.stdout,
level=settings.log_level.upper(),
colorize=True,
)
if settings.log_to_file:
log_file = Path(settings.log_file_path)
log_file.parent.mkdir(parents=True, exist_ok=True)
logger.add(
settings.log_file_path,
level=settings.log_level.upper(),
rotation=settings.log_file_rotation,
retention=settings.log_file_retention,
compression=settings.log_file_compression,
enqueue=True,
encoding="utf-8",
)
================================================
FILE: app/utils/messages.py
================================================
import base64
from typing import List, Optional, Tuple
from loguru import logger
from app.core.http_client import download_image
from app.core.config import settings
from app.core.exceptions import ExternalImageDownloadError, ExternalImageNotAllowedError
from app.models.claude import (
ImageType,
InputMessage,
Role,
ServerToolUseContent,
TextContent,
ImageContent,
ThinkingContent,
ToolResultContent,
ToolUseContent,
URLImageSource,
Base64ImageSource,
)
async def process_messages(
messages: List[InputMessage], system: Optional[str | List[TextContent]] = None
) -> Tuple[str, List[Base64ImageSource]]:
if isinstance(system, str):
merged_text = system
elif system:
merged_text = "\n".join(item.text for item in system)
else:
merged_text = ""
if settings.use_real_roles:
human_prefix = f"\x08{settings.human_name}: "
assistant_prefix = f"\x08{settings.assistant_name}: "
else:
human_prefix = f"{settings.human_name}: "
assistant_prefix = f"{settings.assistant_name}: "
images: List[Base64ImageSource] = []
current_role = Role.USER
for message in messages:
if message.role != current_role:
if merged_text.endswith("\n"):
merged_text = merged_text[:-1]
if message.role == Role.USER:
merged_text += f"\n\n{human_prefix}"
elif message.role == Role.ASSISTANT:
merged_text += f"\n\n{assistant_prefix}"
current_role = message.role
if isinstance(message.content, str):
merged_text += f"{message.content}\n"
else:
for block in message.content:
if isinstance(block, TextContent):
merged_text += f"{block.text}\n"
elif isinstance(block, ThinkingContent):
merged_text += f"<\x08antml:thinking>\n{block.thinking}\n\x08antml:thinking>\n"
elif isinstance(block, ToolUseContent) or isinstance(
block, ServerToolUseContent
):
merged_text += f'<\x08antml:function_calls>\n<\x08antml:invoke name="{block.name}">\n'
for key, value in block.input.items():
merged_text += f'<\x08antml:parameter name="{key}">{value}\x08antml:parameter>\n'
merged_text += "\x08antml:invoke>\n\x08antml:function_calls>\n"
elif isinstance(block, ToolResultContent):
text_content = ""
if isinstance(block.content, str):
text_content = f"{block.content}"
else:
for content_block in block.content:
if isinstance(content_block, TextContent):
text_content += f"{content_block.text}\n"
elif isinstance(content_block, ImageContent):
if isinstance(content_block.source, Base64ImageSource):
images.append(content_block.source)
elif isinstance(content_block.source, URLImageSource):
image_source = await extract_image_from_url(
content_block.source.url
)
if image_source:
images.append(image_source)
text_content += "(image attached)\n"
if text_content.endswith("\n"):
text_content = text_content[:-1]
merged_text += (
f"{text_content}"
)
elif isinstance(block, ImageContent):
if isinstance(block.source, Base64ImageSource):
images.append(block.source)
elif isinstance(block.source, URLImageSource):
image_source = await extract_image_from_url(block.source.url)
if image_source:
images.append(image_source)
if merged_text.endswith("\n"):
merged_text = merged_text[:-1]
return (merged_text, images)
async def extract_image_from_url(url: str) -> Optional[Base64ImageSource]:
"""Extract base64 image from data URL or download from external URL."""
if url.startswith("data:"):
try:
metadata, base64_data = url.split(",", 1)
media_info = metadata[5:]
media_type, encoding = media_info.split(";", 1)
return Base64ImageSource(
type=encoding, media_type=media_type, data=base64_data
)
except Exception:
logger.warning("Failed to extract image from data URL. Skipping image.")
return None
elif settings.allow_external_images and (
url.startswith("http://") or url.startswith("https://")
):
try:
logger.debug(f"Downloading external image: {url}")
content, content_type = await download_image(
url, timeout=settings.request_timeout
)
base64_data = base64.b64encode(content).decode("utf-8")
return Base64ImageSource(
type="base64", media_type=ImageType(content_type), data=base64_data
)
except Exception:
raise ExternalImageDownloadError(url)
elif not settings.allow_external_images and (
url.startswith("http://") or url.startswith("https://")
):
raise ExternalImageNotAllowedError(url)
else:
logger.warning(f"Unsupported URL format: {url}, Skipping image.")
return None
================================================
FILE: app/utils/retry.py
================================================
from loguru import logger
from tenacity import RetryCallState
from app.core.exceptions import AppError
def is_retryable_error(exception):
"""Check if the exception is an AppError with retryable=True"""
return isinstance(exception, AppError) and exception.retryable
def log_before_sleep(retry_state: RetryCallState) -> None:
"""Custom before_sleep callback that safely logs retry attempts."""
attempt_number = retry_state.attempt_number
exception = retry_state.outcome.exception() if retry_state.outcome else None
if exception:
exception_type = type(exception).__name__
logger.warning(
f"Retrying {retry_state.fn.__name__} after attempt {attempt_number} "
f"due to {exception_type}: {str(exception)}"
)
else:
logger.warning(
f"Retrying {retry_state.fn.__name__} after attempt {attempt_number}"
)
================================================
FILE: docker-compose.yml
================================================
version: "3.8"
services:
clove:
build:
context: .
dockerfile: Dockerfile
container_name: clove
restart: unless-stopped
ports:
- "5201:5201"
volumes:
- ./data:/data
environment:
# Server configuration
- HOST=0.0.0.0
- PORT=5201
# Data storage
- DATA_FOLDER=/data
# API Keys (comma-separated)
# - API_KEYS=your-api-key-1,your-api-key-2
# - ADMIN_API_KEYS=your-admin-key-1,your-admin-key-2
# Claude cookies (comma-separated)
# - COOKIES=your-claude-cookie-1,your-claude-cookie-2
# Proxy configuration (optional)
# - PROXY_URL=http://proxy-server:port
# Claude URLs (optional, defaults are usually fine)
# - CLAUDE_AI_URL=https://claude.ai
# - CLAUDE_API_BASEURL=https://api.anthropic.com
- REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-60}
# Logging
- LOG_LEVEL=INFO
- LOG_TO_FILE=true
- LOG_FILE_PATH=/data/logs/app.log
volumes:
data:
driver: local
================================================
FILE: pyproject.toml
================================================
[project]
name = "clove-proxy"
version = "0.3.1"
description = "A Claude.ai reverse proxy"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "mirrorange", email = "orange@freesia.ink"},
]
keywords = ["claude", "ai", "proxy", "fastapi"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Education",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"fastapi>=0.115.14",
"httpx>=0.28.1",
"json5>=0.12.0",
"loguru>=0.7.3",
"pydantic>=2.11.7",
"pydantic-settings>=2.10.1",
"tenacity>=9.1.2",
"tiktoken>=0.9.0",
"uvicorn>=0.35.0",
]
[project.urls]
"Homepage" = "https://github.com/mirrorange/clove"
"Bug Tracker" = "https://github.com/mirrorange/clove/issues"
"Documentation" = "https://github.com/mirrorange/clove#readme"
[project.scripts]
clove = "app.main:main"
[project.optional-dependencies]
curl = [
"curl-cffi>=0.11.4",
]
rnet = [
"rnet>=3.0.0rc14",
]
dev = [
"build>=1.0.0",
"ruff>=0.12.2",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build]
include = [
"app/**/*",
"README.md",
"LICENSE",
]
exclude = [
"app/**/__pycache__",
"app/**/*.pyc",
"app/**/*.pyo",
"app/**/test_*.py",
"app/**/*_test.py",
]
[tool.hatch.build.targets.wheel]
packages = ["app"]
[tool.hatch.build.targets.wheel.force-include]
"app/static" = "app/static"
"app/locales" = "app/locales"
================================================
FILE: scripts/build_wheel.py
================================================
#!/usr/bin/env python3
"""Build script for Clove - builds frontend and creates Python wheel."""
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
def run_command(cmd, cwd=None, check=True):
"""Run a shell command and return the result."""
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
if check and result.returncode != 0:
print(f"Error: {result.stderr}")
sys.exit(1)
return result
def clean_directories():
"""Clean build directories."""
print("\n📦 Cleaning build directories...")
dirs_to_clean = ["dist", "build", "app.egg-info", "clove.egg-info"]
for dir_name in dirs_to_clean:
if Path(dir_name).exists():
shutil.rmtree(dir_name)
print(f" ✓ Removed {dir_name}")
def check_node_installed():
"""Check if Node.js is installed."""
try:
result = run_command(["node", "--version"], check=False)
if result.returncode == 0:
print(f" ✓ Node.js {result.stdout.strip()} detected")
return True
except FileNotFoundError:
pass
print(" ✗ Node.js not found. Please install Node.js to build the frontend.")
return False
def check_pnpm_installed():
"""Check if pnpm is installed."""
try:
result = run_command(["pnpm", "--version"], check=False)
if result.returncode == 0:
print(f" ✓ pnpm {result.stdout.strip()} detected")
return True
except FileNotFoundError:
pass
print(" ✗ pnpm not found. Installing pnpm...")
run_command(["npm", "install", "-g", "pnpm"])
return True
def build_frontend():
"""Build the frontend application."""
print("\n🎨 Building frontend...")
front_dir = Path("front")
if not front_dir.exists():
print(" ✗ Frontend directory not found")
return False
if not check_node_installed():
return False
if not check_pnpm_installed():
return False
if not (front_dir / "node_modules").exists():
print(" 📦 Installing frontend dependencies...")
run_command(["pnpm", "install"], cwd=front_dir)
print(" 🔨 Building frontend assets...")
run_command(["pnpm", "run", "build"], cwd=front_dir)
print(" 📂 Copying built files to app/static...")
static_dir = Path("app/static")
if static_dir.exists():
shutil.rmtree(static_dir)
shutil.copytree(front_dir / "dist", static_dir)
print(" ✓ Frontend build complete")
return True
def build_wheel():
"""Build the Python wheel."""
print("\n🐍 Building Python wheel...")
try:
import build
except ImportError:
print(" 📦 Installing build tool...")
run_command([sys.executable, "-m", "pip", "install", "build"])
run_command([sys.executable, "-m", "build", "--wheel"])
dist_dir = Path("dist")
if dist_dir.exists():
wheels = list(dist_dir.glob("*.whl"))
if wheels:
print(f" ✓ Created wheel: {wheels[0].name}")
return True
print(" ✗ Failed to create wheel")
return False
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Build script for Clove - builds frontend and creates Python wheel."
)
parser.add_argument(
"--skip-frontend",
action="store_true",
help="Skip frontend build and only build the Python wheel",
)
parser.add_argument(
"--no-clean",
action="store_true",
help="Skip cleaning build directories before building",
)
return parser.parse_args()
def main():
"""Main build process."""
args = parse_args()
print("🚀 Building Clove...")
if not args.no_clean:
clean_directories()
if args.skip_frontend:
print("\n⏭️ Frontend build skipped (--skip-frontend specified)")
if not Path("app/static").exists():
print(
"⚠️ No static files found. The wheel will be built without frontend assets."
)
print(
" You may need to build the frontend separately or copy static files manually."
)
else:
frontend_built = build_frontend()
if not frontend_built:
print("\n⚠️ Frontend build skipped. Using existing static files.")
if not Path("app/static").exists():
print(
"❌ No static files found. Please build frontend manually or ensure app/static exists."
)
sys.exit(1)
if build_wheel():
print("\n✅ Build complete!")
print("\n📦 Installation instructions:")
print(" 1. Install the wheel:")
print(" pip install dist/*.whl")
print(" 2. Run Clove:")
print(" clove")
print("\n📝 Note: You can also install in development mode:")
print(" pip install -e .")
else:
print("\n❌ Build failed!")
sys.exit(1)
if __name__ == "__main__":
main()
================================================
FILE: tests/test_claude_request_models.py
================================================
import unittest
from app.models.claude import MessagesAPIRequest
class MessagesAPIRequestToolParsingTests(unittest.TestCase):
def test_accepts_custom_tool_payload_without_top_level_input_schema(self) -> None:
request = MessagesAPIRequest.model_validate(
{
"model": "claude-opus-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Search for the latest CNY USD rate"}],
"tools": [
{
"type": "custom",
"name": "WebSearch",
"custom": {
"description": "Search the web for public information",
"input_schema": {
"type": "object",
"properties": {
"query": {"type": "string"},
},
"required": ["query"],
},
},
}
],
}
)
self.assertEqual(request.tools[0].name, "WebSearch")
def test_accepts_server_web_search_tool_without_input_schema(self) -> None:
request = MessagesAPIRequest.model_validate(
{
"model": "claude-opus-4-20250514",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Search for the latest CNY USD rate"}],
"tools": [
{
"type": "web_search_20250305",
"name": "web_search",
"max_uses": 5,
}
],
}
)
self.assertEqual(request.tools[0].name, "web_search")
if __name__ == "__main__":
unittest.main()