diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..06d6db72583e48da59d53ca7fc8c620b409f146c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# ============================================ +# Multi-stage Dockerfile for Antigravity API Proxy +# For deployment to HuggingFace Spaces +# ============================================ + +# Stage 1: Build Frontend +FROM node:20-alpine AS frontend-builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --legacy-peer-deps +COPY . . +RUN npm run build + +# Stage 2: Build Backend +FROM rust:1.75-slim AS backend-builder +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app + +# Copy server source +COPY server/ ./ + +# Build release binary +RUN cargo build --release + +# Stage 3: Runtime +FROM debian:bookworm-slim + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user (required by HuggingFace Spaces) +RUN useradd -m -u 1000 user +USER user +ENV HOME=/home/user +WORKDIR $HOME/app + +# Copy backend binary +COPY --chown=user --from=backend-builder /app/target/release/antigravity-server ./ + +# Copy frontend static files +COPY --chown=user --from=frontend-builder /app/dist ./static + +# Create data directory +RUN mkdir -p /home/user/app/data + +# Expose port +EXPOSE 7860 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:7860/healthz || exit 1 + +# Run the server +CMD ["./antigravity-server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5a764c10a327f2c276d84ab3b7703c7c7cc11457 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +--- +title: Antigravity API Proxy +emoji: "\U0001F680" +colorFrom: blue +colorTo: purple +sdk: docker +app_port: 7860 +pinned: false +license: cc-by-nc-sa-4.0 +--- + +# Antigravity API Proxy + +A cloud-deployed API proxy service that converts Google AI Web sessions to standard API interfaces (OpenAI, Claude, Gemini formats). + +## Features + +- **Multi-Protocol Support**: OpenAI, Claude, and Gemini API formats +- **Token Management**: Automatic token refresh and rotation +- **Web UI**: Account and configuration management interface +- **Model Mapping**: Flexible model aliasing +- **API Key Authentication**: Secure access with multiple API keys + +## Authentication + +### Frontend Access (Web UI) +The web management interface is protected by HuggingFace Spaces password. +Set this in: **Settings → Access control → Password** + +### API Proxy Authentication +Configure API keys in HuggingFace **Settings → Secrets**: + +| Secret Name | Description | +|-------------|-------------| +| `API_KEYS` | Comma-separated list: `key1,key2,key3` | +| `API_KEY_1` | First API key | +| `API_KEY_2` | Second API key | +| `API_KEY` | Single API key (backward compatible) | + +**Example:** +``` +API_KEYS = sk-abc123,sk-def456,sk-ghi789 +``` + +When calling the API, include your key in the request header: +```bash +# Option 1: Authorization header +curl -H "Authorization: Bearer sk-abc123" ... + +# Option 2: X-API-Key header +curl -H "X-API-Key: sk-abc123" ... +``` + +## Endpoints + +### OpenAI Compatible +- `POST /v1/chat/completions` - Chat completions +- `GET /v1/models` - List models + +### Claude Compatible +- `POST /v1/messages` - Messages API +- `GET /v1/models/claude` - List Claude models + +### Gemini Native +- `POST /v1beta/models/:model:generateContent` - Generate content +- `GET /v1beta/models` - List Gemini models + +## Usage Examples + +### OpenAI SDK (Python) +```python +from openai import OpenAI + +client = OpenAI( + base_url="https://your-space.hf.space/v1", + api_key="sk-your-api-key" +) + +response = client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello!"}] +) +print(response.choices[0].message.content) +``` + +### Claude SDK (Python) +```python +import anthropic + +client = anthropic.Anthropic( + base_url="https://your-space.hf.space", + api_key="sk-your-api-key" +) + +message = client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=1024, + messages=[{"role": "user", "content": "Hello!"}] +) +print(message.content) +``` + +### cURL +```bash +curl -X POST https://your-space.hf.space/v1/chat/completions \ + -H "Authorization: Bearer sk-your-api-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +## Configuration + +Access the web UI at the root URL to manage: +- **Accounts**: Add via Refresh Token +- **Model Mappings**: Map OpenAI/Claude models to Gemini +- **Proxy Settings**: Configure upstream proxy if needed + +## Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `API_KEYS` | Comma-separated API keys | No* | +| `API_KEY_1`, `API_KEY_2`, ... | Individual API keys | No* | +| `PORT` | Server port (default: 7860) | No | + +*If no API keys are configured, authentication is disabled. + +## Security Notes + +1. **Always set API keys** in production to prevent unauthorized access +2. **Enable Space password** to protect the management UI +3. Refresh tokens are stored in `/data` persistent storage +4. Consider the security implications of storing tokens in cloud environments diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..4f063b022a4c0658165aa3296598bbe2ec8ed907 --- /dev/null +++ b/index.html @@ -0,0 +1,59 @@ + + + + + + + + Antigravity Tools + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..ea594af685d9dc4339763d14b0390cf1c87cefc6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1886 @@ +{ + "name": "antigravity-tools-cloud", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true + }, + "@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true + }, + "@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "requires": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true + }, + "@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "requires": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + } + }, + "@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true + }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true + }, + "@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "requires": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + } + }, + "@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "requires": { + "@babel/types": "^7.28.5" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.27.1" + } + }, + "@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" + }, + "@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + } + }, + "@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + } + }, + "@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, + "@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "dev": true, + "optional": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "requires": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "dependencies": { + "immer": { + "version": "11.1.3", + "resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==" + } + } + }, + "@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "dev": true, + "optional": true + }, + "@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, + "@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "requires": { + "@babel/types": "^7.28.2" + } + }, + "@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, + "requires": { + "csstype": "^3.2.2" + } + }, + "@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true + }, + "@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, + "@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "requires": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "requires": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + } + }, + "baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "requires": { + "fill-range": "^7.1.1" + } + }, + "browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "requires": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + } + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001762", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "dev": true + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, + "d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "requires": { + "internmap": "1 - 2" + } + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "requires": { + "d3-path": "^3.1.0" + } + }, + "d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "daisyui": { + "version": "5.5.14", + "resolved": "https://registry.npmmirror.com/daisyui/-/daisyui-5.5.14.tgz", + "integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==" + }, + "date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==" + }, + "debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "requires": { + "ms": "^2.1.3" + } + }, + "decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, + "didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true + }, + "es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==" + }, + "esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true + }, + "eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, + "fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true + }, + "framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "requires": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + } + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "requires": { + "void-elements": "3.1.0" + } + }, + "i18next": { + "version": "25.7.3", + "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.7.3.tgz", + "integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==", + "requires": { + "@babel/runtime": "^7.28.4" + } + }, + "i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "requires": { + "@babel/runtime": "^7.23.2" + } + }, + "immer": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==" + }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "requires": { + "hasown": "^2.0.2" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "lucide-react": { + "version": "0.561.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.561.0.tgz", + "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==" + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "requires": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + } + }, + "motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmmirror.com/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "requires": { + "motion-utils": "^11.18.1" + } + }, + "motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmmirror.com/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==" + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true + }, + "node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true + }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + }, + "pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true + }, + "postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "requires": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + } + }, + "postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + } + }, + "postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "requires": { + "camelcase-css": "^2.0.1" + } + }, + "postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "requires": { + "lilconfig": "^3.1.1" + } + }, + "postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.1.1" + } + }, + "postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, + "react": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==" + }, + "react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "requires": { + "scheduler": "^0.27.0" + } + }, + "react-i18next": { + "version": "16.5.0", + "resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.0.tgz", + "integrity": "sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw==", + "requires": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + } + }, + "react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "requires": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + } + }, + "react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true + }, + "react-router": { + "version": "7.11.0", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.11.0.tgz", + "integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==", + "requires": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + } + }, + "react-router-dom": { + "version": "7.11.0", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.11.0.tgz", + "integrity": "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==", + "requires": { + "react-router": "7.11.0" + } + }, + "read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "requires": { + "pify": "^2.3.0" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "recharts": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", + "requires": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + } + }, + "redux": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==" + }, + "reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, + "resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "requires": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true + }, + "rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "@types/estree": "1.0.8", + "fsevents": "~2.3.2" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, + "sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==" + }, + "tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "requires": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + } + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "requires": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true + } + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "requires": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + } + }, + "use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "requires": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "vite": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.0.tgz", + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", + "dev": true, + "requires": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "fsevents": "~2.3.3", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "dependencies": { + "picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true + } + } + }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9ace0854b243501a1ea40cec1dc338ea63e06b56 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "antigravity-tools-cloud", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "Cloud-deployed API proxy for AI services (HuggingFace Spaces)", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "clsx": "^2.1.1", + "daisyui": "^5.5.13", + "date-fns": "^4.1.0", + "framer-motion": "^11.13.1", + "i18next": "^25.7.2", + "i18next-browser-languagedetector": "^8.2.0", + "lucide-react": "^0.561.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-i18next": "^16.5.0", + "react-router-dom": "^7.10.1", + "recharts": "^3.5.1", + "tailwind-merge": "^2.3.0", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "autoprefixer": "^10.4.22", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000000000000000000000000000000000000..fef1b2256d61e21330bc8de1cb6978cfac54a9a9 --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..ba8b0f0ec446864866ad13706e58e397a60937a8 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,2475 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "antigravity-server" +version = "1.0.0" +dependencies = [ + "anyhow", + "async-stream", + "axum", + "base64", + "bytes", + "chrono", + "dashmap", + "dirs", + "eventsource-stream", + "futures", + "hyper", + "hyper-util", + "once_cell", + "pin-project", + "rand", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-appender", + "tracing-log", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower 0.5.2", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.17", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "time", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9747e91771f56fd7893e1164abd78febd14a670ceec257caad15e051de35f06" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..0da27ce22cf5ea9ebefac102c693a4c27a0d4ed1 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "antigravity-server" +version = "1.0.0" +description = "Antigravity API Proxy Server for HuggingFace Spaces" +authors = ["Antigravity Team"] +license = "CC-BY-NC-SA-4.0" +edition = "2021" + +[dependencies] +# Web Framework +axum = { version = "0.7", features = ["macros"] } +hyper = { version = "1", features = ["full"] } +hyper-util = { version = "0.1", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "trace", "fs"] } +tokio = { version = "1", features = ["full"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# HTTP Client +reqwest = { version = "0.12", features = ["json", "stream", "socks"] } + +# Utilities +uuid = { version = "1.10", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +dirs = "5.0" +base64 = "0.22" +url = "2.5.7" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "time"] } +tracing-appender = "0.2.4" +tracing-log = "0.2.0" + +# Error Handling +thiserror = "2.0.17" +anyhow = "1.0" + +# Async & Streaming +futures = "0.3" +async-stream = "0.3.6" +eventsource-stream = "0.2" +pin-project = "1.1" +bytes = "1.5" + +# Concurrency +dashmap = "6.1" + +# Other +rand = "0.8" +regex = "1.12.2" +once_cell = "1.19" diff --git a/server/src/api/accounts.rs b/server/src/api/accounts.rs new file mode 100644 index 0000000000000000000000000000000000000000..9751442ce7f25529c75216fcfb843da378f568fa --- /dev/null +++ b/server/src/api/accounts.rs @@ -0,0 +1,205 @@ +use axum::{ + extract::Path, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::models::{Account, TokenData, QuotaData}; +use crate::modules; +use crate::error::AppError; + +/// API response wrapper +#[derive(Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse { + pub fn success(data: T) -> Json { + Json(Self { + success: true, + data: Some(data), + error: None, + }) + } + + pub fn error(message: String) -> Json { + Json(Self { + success: false, + data: None, + error: Some(message), + }) + } +} + +/// List all accounts +pub async fn list_accounts() -> Result>>, AppError> { + let accounts = modules::list_accounts().map_err(AppError::Account)?; + Ok(ApiResponse::success(accounts)) +} + +/// Add account request +#[derive(Deserialize)] +pub struct AddAccountRequest { + pub email: String, + pub refresh_token: String, +} + +/// Add new account +pub async fn add_account( + Json(req): Json, +) -> Result>, AppError> { + modules::logger::log_info(&format!("Adding account: {}", req.email)); + + // 1. Use refresh_token to get access_token + let token_res = modules::oauth::refresh_access_token(&req.refresh_token) + .await + .map_err(AppError::OAuth)?; + + // 2. Get user info + let user_info = modules::oauth::get_user_info(&token_res.access_token) + .await + .map_err(AppError::OAuth)?; + + // 3. Construct TokenData + let token = TokenData::new( + token_res.access_token, + req.refresh_token, + token_res.expires_in, + Some(user_info.email.clone()), + None, + None, + ); + + // 4. Add or update account using real email + let mut account = modules::upsert_account( + user_info.email.clone(), + user_info.get_display_name(), + token, + ).map_err(AppError::Account)?; + + modules::logger::log_info(&format!("Account added successfully: {}", account.email)); + + // 5. Auto refresh quota + let _ = internal_refresh_account_quota(&mut account).await; + + Ok(ApiResponse::success(account)) +} + +/// Delete account +pub async fn delete_account( + Path(account_id): Path, +) -> Result>, AppError> { + modules::logger::log_info(&format!("Deleting account: {}", account_id)); + modules::delete_account(&account_id).map_err(AppError::Account)?; + modules::logger::log_info(&format!("Account deleted: {}", account_id)); + Ok(ApiResponse::success(())) +} + +/// Refresh account quota +pub async fn refresh_quota( + Path(account_id): Path, +) -> Result>, AppError> { + modules::logger::log_info(&format!("Refreshing quota for: {}", account_id)); + + let mut account = modules::load_account(&account_id).map_err(AppError::Account)?; + let quota = modules::account::fetch_quota_with_retry(&mut account).await?; + + // Update account quota + modules::update_account_quota(&account_id, quota.clone()).map_err(AppError::Account)?; + + Ok(ApiResponse::success(quota)) +} + +/// Refresh stats response +#[derive(Serialize)] +pub struct RefreshStats { + total: usize, + success: usize, + failed: usize, + details: Vec, +} + +/// Refresh all account quotas +pub async fn refresh_all_quotas() -> Result>, AppError> { + modules::logger::log_info("Starting batch quota refresh for all accounts"); + let accounts = modules::list_accounts().map_err(AppError::Account)?; + + let mut success = 0; + let mut failed = 0; + let mut details = Vec::new(); + + // Serial processing to ensure persistence safety + for mut account in accounts { + if let Some(ref q) = account.quota { + if q.is_forbidden { + modules::logger::log_info(&format!("Skipping {} (Forbidden)", account.email)); + continue; + } + } + + modules::logger::log_info(&format!("Processing {}", account.email)); + + match modules::account::fetch_quota_with_retry(&mut account).await { + Ok(quota) => { + if let Err(e) = modules::update_account_quota(&account.id, quota) { + failed += 1; + let msg = format!("Account {}: Save quota failed - {}", account.email, e); + details.push(msg.clone()); + modules::logger::log_error(&msg); + } else { + success += 1; + modules::logger::log_info("Success"); + } + }, + Err(e) => { + failed += 1; + let msg = format!("Account {}: Fetch quota failed - {}", account.email, e); + details.push(msg.clone()); + modules::logger::log_error(&msg); + } + } + } + + modules::logger::log_info(&format!("Batch refresh completed: {} success, {} failed", success, failed)); + Ok(ApiResponse::success(RefreshStats { total: success + failed, success, failed, details })) +} + +/// Get current account +pub async fn get_current_account() -> Result>>, AppError> { + let account_id = modules::get_current_account_id().map_err(AppError::Account)?; + + if let Some(id) = account_id { + let account = modules::load_account(&id).map_err(AppError::Account)?; + Ok(ApiResponse::success(Some(account))) + } else { + Ok(ApiResponse::success(None)) + } +} + +/// Set current account +pub async fn set_current_account( + Path(account_id): Path, +) -> Result>, AppError> { + modules::logger::log_info(&format!("Setting current account: {}", account_id)); + modules::set_current_account_id(&account_id).map_err(AppError::Account)?; + Ok(ApiResponse::success(())) +} + +/// Internal helper: auto refresh quota after adding account +async fn internal_refresh_account_quota(account: &mut Account) -> Result { + modules::logger::log_info(&format!("Auto refreshing quota: {}", account.email)); + + match modules::account::fetch_quota_with_retry(account).await { + Ok(quota) => { + let _ = modules::update_account_quota(&account.id, quota.clone()); + Ok(quota) + }, + Err(e) => { + modules::logger::log_warn(&format!("Auto refresh quota failed ({}): {}", account.email, e)); + Err(e.to_string()) + } + } +} diff --git a/server/src/api/config.rs b/server/src/api/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..fcf7dfda1ef163af541ac92e2f2fbdb6c04da220 --- /dev/null +++ b/server/src/api/config.rs @@ -0,0 +1,39 @@ +use axum::Json; +use serde::Serialize; + +use crate::models::AppConfig; +use crate::modules; +use crate::error::AppError; + +/// API response wrapper +#[derive(Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse { + pub fn success(data: T) -> Json { + Json(Self { + success: true, + data: Some(data), + error: None, + }) + } +} + +/// Load configuration +pub async fn load_config() -> Result>, AppError> { + let config = modules::load_app_config().map_err(AppError::Config)?; + Ok(ApiResponse::success(config)) +} + +/// Save configuration +pub async fn save_config( + Json(config): Json, +) -> Result>, AppError> { + modules::save_app_config(&config).map_err(AppError::Config)?; + modules::logger::log_info("Configuration saved"); + Ok(ApiResponse::success(())) +} diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..d4cd1450a4e1ecad5147b0ebeced4307568926a3 --- /dev/null +++ b/server/src/api/mod.rs @@ -0,0 +1,42 @@ +pub mod accounts; +pub mod config; +pub mod proxy; + +use axum::{Router, routing::{get, post, delete}}; + +/// Create API routes (without state for management endpoints) +pub fn api_routes() -> Router +where + S: Clone + Send + Sync + 'static, +{ + Router::new() + // Account management + .route("/api/accounts", get(accounts::list_accounts)) + .route("/api/accounts", post(accounts::add_account)) + .route("/api/accounts/:id", delete(accounts::delete_account)) + .route("/api/accounts/:id/quota", post(accounts::refresh_quota)) + .route("/api/accounts/refresh-all", post(accounts::refresh_all_quotas)) + .route("/api/accounts/current", get(accounts::get_current_account)) + .route("/api/accounts/:id/set-current", post(accounts::set_current_account)) + + // Configuration management + .route("/api/config", get(config::load_config)) + .route("/api/config", post(config::save_config)) + + // Proxy management + .route("/api/proxy/status", get(proxy::get_proxy_status)) + .route("/api/proxy/mapping", post(proxy::update_model_mapping)) + .route("/api/proxy/restart", post(proxy::restart_proxy)) + .route("/api/proxy/generate-key", post(proxy::generate_api_key)) + + // Health check + .route("/api/health", get(health_check)) +} + +/// Health check endpoint +async fn health_check() -> axum::Json { + axum::Json(serde_json::json!({ + "status": "ok", + "version": env!("CARGO_PKG_VERSION") + })) +} diff --git a/server/src/api/proxy.rs b/server/src/api/proxy.rs new file mode 100644 index 0000000000000000000000000000000000000000..281ff98d72b90fbfb7efca37530ee99da4c95e4c --- /dev/null +++ b/server/src/api/proxy.rs @@ -0,0 +1,90 @@ +use axum::Json; +use serde::Serialize; +use uuid::Uuid; + +use crate::proxy::config::ProxyConfig; +use crate::modules; +use crate::error::AppError; + +use super::config::ApiResponse; + +/// Proxy status response +#[derive(Serialize)] +pub struct ProxyStatus { + pub running: bool, + pub port: u16, + pub base_url: String, + #[serde(rename = "active_accounts")] + pub active_accounts: usize, +} + +/// Get proxy status +/// In cloud deployment mode, the proxy is always running +pub async fn get_proxy_status() -> Result>, AppError> { + let _config = modules::load_app_config().map_err(AppError::Config)?; + let account_index = modules::load_account_index().map_err(AppError::Account)?; + + let port = std::env::var("PORT") + .unwrap_or_else(|_| "7860".to_string()) + .parse::() + .unwrap_or(7860); + + let base_url = std::env::var("SPACE_HOST") + .map(|host| format!("https://{}", host)) + .unwrap_or_else(|_| format!("http://localhost:{}", port)); + + let status = ProxyStatus { + running: true, // Always running in cloud mode + port, + base_url, + active_accounts: account_index.accounts.len(), + }; + + Ok(ApiResponse::success(status)) +} + +/// Update model mapping +pub async fn update_model_mapping( + Json(config): Json, +) -> Result>, AppError> { + // Load current config and update proxy section + let mut app_config = modules::load_app_config().map_err(AppError::Config)?; + app_config.proxy.anthropic_mapping = config.anthropic_mapping; + app_config.proxy.openai_mapping = config.openai_mapping; + app_config.proxy.custom_mapping = config.custom_mapping; + + modules::save_app_config(&app_config).map_err(AppError::Config)?; + modules::logger::log_info("Model mapping updated"); + + Ok(ApiResponse::success(())) +} + +/// Restart proxy service (reload configuration) +/// In cloud mode, this doesn't actually restart the server, +/// but signals that configuration has been updated +pub async fn restart_proxy( + Json(config): Json, +) -> Result>, AppError> { + // Load and update configuration + let mut app_config = modules::load_app_config().map_err(AppError::Config)?; + app_config.proxy = config; + modules::save_app_config(&app_config).map_err(AppError::Config)?; + + modules::logger::log_info("Proxy configuration updated (restart not needed in cloud mode)"); + + Ok(ApiResponse::success(())) +} + +/// Generate a new API key +pub async fn generate_api_key() -> Result>, AppError> { + let new_key = format!("ag-{}", Uuid::new_v4().to_string().replace("-", "")); + + // Update config with new key + let mut app_config = modules::load_app_config().map_err(AppError::Config)?; + app_config.proxy.api_key = new_key.clone(); + modules::save_app_config(&app_config).map_err(AppError::Config)?; + + modules::logger::log_info("New API key generated"); + + Ok(ApiResponse::success(new_key)) +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000000000000000000000000000000000000..beedb4fde14f56777a8c77d590f8919782b978ff --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,65 @@ +use serde::Serialize; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AppError { + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("OAuth error: {0}")] + OAuth(String), + + #[error("Configuration error: {0}")] + Config(String), + + #[error("Account error: {0}")] + Account(String), + + #[error("Proxy error: {0}")] + Proxy(String), + + #[error("Unknown error: {0}")] + Unknown(String), +} + +// Implement Serialize for API responses +impl Serialize for AppError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.to_string().as_str()) + } +} + +// Result type alias +pub type AppResult = Result; + +// Implement IntoResponse for Axum +impl axum::response::IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let status = match &self { + AppError::Network(_) => axum::http::StatusCode::BAD_GATEWAY, + AppError::Io(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + AppError::Json(_) => axum::http::StatusCode::BAD_REQUEST, + AppError::OAuth(_) => axum::http::StatusCode::UNAUTHORIZED, + AppError::Config(_) => axum::http::StatusCode::BAD_REQUEST, + AppError::Account(_) => axum::http::StatusCode::NOT_FOUND, + AppError::Proxy(_) => axum::http::StatusCode::SERVICE_UNAVAILABLE, + AppError::Unknown(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + }; + + let body = serde_json::json!({ + "success": false, + "error": self.to_string() + }); + + (status, axum::Json(body)).into_response() + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..17e562d9b8975fdb7e7a3b48af64ec7cef6d832e --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,8 @@ +pub mod models; +pub mod modules; +pub mod utils; +pub mod proxy; +pub mod error; +pub mod api; + +pub use proxy::ProxyConfig; diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..4aa58d5537edcc88b8fd9c18de72e84d2dfb01d5 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; +use std::path::PathBuf; +use axum::{Router, extract::DefaultBodyLimit}; +use tower_http::{trace::TraceLayer, services::ServeDir}; +use tracing::info; + +use antigravity_server::{ + modules, + proxy::TokenManager, + api, +}; + +#[tokio::main] +async fn main() { + // Initialize logger + modules::logger::init_logger(); + + info!("Starting Antigravity API Proxy Server..."); + + // Get data directory + let data_dir = if PathBuf::from("/data").exists() { + PathBuf::from("/data") + } else { + dirs::home_dir() + .expect("Cannot get home directory") + .join(".antigravity_tools") + }; + + info!("Using data directory: {:?}", data_dir); + + // Load configuration + let config = modules::load_app_config().unwrap_or_default(); + let proxy_config = config.proxy.clone(); + + // Initialize token manager + let token_manager = Arc::new(TokenManager::new(data_dir.clone())); + + // Load accounts from file system + match token_manager.load_accounts().await { + Ok(count) => { + info!("Loaded {} accounts into token manager", count); + } + Err(e) => { + info!("Could not load accounts: {} (this is ok for first run)", e); + } + } + + // Build proxy state + let mapping_state = Arc::new(tokio::sync::RwLock::new(proxy_config.anthropic_mapping.clone())); + let openai_mapping_state = Arc::new(tokio::sync::RwLock::new(proxy_config.openai_mapping.clone())); + let custom_mapping_state = Arc::new(tokio::sync::RwLock::new(proxy_config.custom_mapping.clone())); + let proxy_state = Arc::new(tokio::sync::RwLock::new(proxy_config.upstream_proxy.clone())); + + let app_state = antigravity_server::proxy::server::AppState { + token_manager: token_manager.clone(), + anthropic_mapping: mapping_state, + openai_mapping: openai_mapping_state, + custom_mapping: custom_mapping_state, + request_timeout: 300, + thought_signature_map: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + upstream_proxy: proxy_state, + upstream: Arc::new(antigravity_server::proxy::upstream::client::UpstreamClient::new( + Some(proxy_config.upstream_proxy.clone()) + )), + }; + + // Build routes + use antigravity_server::proxy::handlers; + use axum::routing::{get, post}; + + let proxy_routes = Router::new() + // OpenAI Protocol + .route("/v1/models", get(handlers::openai::handle_list_models)) + .route("/v1/chat/completions", post(handlers::openai::handle_chat_completions)) + .route("/v1/completions", post(handlers::openai::handle_completions)) + .route("/v1/responses", post(handlers::openai::handle_completions)) + // Claude Protocol + .route("/v1/messages", post(handlers::claude::handle_messages)) + .route("/v1/messages/count_tokens", post(handlers::claude::handle_count_tokens)) + .route("/v1/models/claude", get(handlers::claude::handle_list_models)) + // Gemini Protocol + .route("/v1beta/models", get(handlers::gemini::handle_list_models)) + .route("/v1beta/models/:model", get(handlers::gemini::handle_get_model).post(handlers::gemini::handle_generate)) + .route("/v1beta/models/:model/countTokens", post(handlers::gemini::handle_count_tokens)) + // Health check + .route("/healthz", get(health_check)) + .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) + .layer(TraceLayer::new_for_http()) + .layer(axum::middleware::from_fn(antigravity_server::proxy::middleware::auth_middleware)) + .layer(antigravity_server::proxy::middleware::cors_layer()) + .with_state(app_state); + + // Combine API routes and proxy routes + // Apply basic auth middleware to admin UI and management API + // Proxy routes (/v1/*) are protected by API key auth instead + let app = Router::new() + .merge(api::api_routes::<()>()) + .merge(proxy_routes) + .fallback_service(ServeDir::new("static")) + .layer(axum::middleware::from_fn(antigravity_server::proxy::middleware::basic_auth_middleware)); + + // Bind to port 7860 (HuggingFace Spaces default) + let port = std::env::var("PORT") + .unwrap_or_else(|_| "7860".to_string()) + .parse::() + .unwrap_or(7860); + + let addr = format!("0.0.0.0:{}", port); + info!("Server listening on http://{}", addr); + + let listener = tokio::net::TcpListener::bind(&addr) + .await + .expect("Failed to bind address"); + + axum::serve(listener, app) + .await + .expect("Server error"); +} + +/// Health check handler +async fn health_check() -> axum::Json { + axum::Json(serde_json::json!({ + "status": "ok", + "version": env!("CARGO_PKG_VERSION") + })) +} diff --git a/server/src/models/account.rs b/server/src/models/account.rs new file mode 100644 index 0000000000000000000000000000000000000000..e1d50287bf4092726f3036bd8405613a491178dd --- /dev/null +++ b/server/src/models/account.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; +use super::{token::TokenData, quota::QuotaData}; + +/// Account data structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub id: String, + pub email: String, + pub name: Option, + pub token: TokenData, + pub quota: Option, + pub created_at: i64, + pub last_used: i64, +} + +impl Account { + pub fn new(id: String, email: String, token: TokenData) -> Self { + let now = chrono::Utc::now().timestamp(); + Self { + id, + email, + name: None, + token, + quota: None, + created_at: now, + last_used: now, + } + } + + pub fn update_last_used(&mut self) { + self.last_used = chrono::Utc::now().timestamp(); + } + + pub fn update_quota(&mut self, quota: QuotaData) { + self.quota = Some(quota); + } +} + +/// Account index (accounts.json) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountIndex { + pub version: String, + pub accounts: Vec, + pub current_account_id: Option, +} + +/// Account summary +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountSummary { + pub id: String, + pub email: String, + pub name: Option, + pub created_at: i64, + pub last_used: i64, +} + +impl AccountIndex { + pub fn new() -> Self { + Self { + version: "2.0".to_string(), + accounts: Vec::new(), + current_account_id: None, + } + } +} + +impl Default for AccountIndex { + fn default() -> Self { + Self::new() + } +} diff --git a/server/src/models/config.rs b/server/src/models/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..e77009f417df356d6ad52dcc944167105cf03e75 --- /dev/null +++ b/server/src/models/config.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use crate::proxy::ProxyConfig; + +/// Application configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub language: String, + pub theme: String, + pub auto_refresh: bool, + pub refresh_interval: i32, // minutes + #[serde(default)] + pub proxy: ProxyConfig, +} + +impl AppConfig { + pub fn new() -> Self { + Self { + language: "zh".to_string(), + theme: "system".to_string(), + auto_refresh: false, + refresh_interval: 15, + proxy: ProxyConfig::default(), + } + } +} + +impl Default for AppConfig { + fn default() -> Self { + Self::new() + } +} diff --git a/server/src/models/mod.rs b/server/src/models/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..1b6d060c67413c4cde8bde617d0f8d857fd66c86 --- /dev/null +++ b/server/src/models/mod.rs @@ -0,0 +1,9 @@ +pub mod account; +pub mod token; +pub mod quota; +pub mod config; + +pub use account::{Account, AccountIndex, AccountSummary}; +pub use token::TokenData; +pub use quota::QuotaData; +pub use config::AppConfig; diff --git a/server/src/models/quota.rs b/server/src/models/quota.rs new file mode 100644 index 0000000000000000000000000000000000000000..2510ec320f8e512222affd234dda21856625cf07 --- /dev/null +++ b/server/src/models/quota.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +/// Model quota information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelQuota { + pub name: String, + pub percentage: i32, // Remaining percentage 0-100 + pub reset_time: String, +} + +/// Quota data structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuotaData { + pub models: Vec, + pub last_updated: i64, + #[serde(default)] + pub is_forbidden: bool, + /// Subscription tier (FREE/PRO/ULTRA) + #[serde(default)] + pub subscription_tier: Option, +} + +impl QuotaData { + pub fn new() -> Self { + Self { + models: Vec::new(), + last_updated: chrono::Utc::now().timestamp(), + is_forbidden: false, + subscription_tier: None, + } + } + + pub fn add_model(&mut self, name: String, percentage: i32, reset_time: String) { + self.models.push(ModelQuota { + name, + percentage, + reset_time, + }); + } +} + +impl Default for QuotaData { + fn default() -> Self { + Self::new() + } +} diff --git a/server/src/models/token.rs b/server/src/models/token.rs new file mode 100644 index 0000000000000000000000000000000000000000..3cf33f99a74816a6e5cb06302746ca3b21d07376 --- /dev/null +++ b/server/src/models/token.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenData { + pub access_token: String, + pub refresh_token: String, + pub expires_in: i64, + pub expiry_timestamp: i64, + pub token_type: String, + pub email: Option, + /// Google Cloud project ID for API requests + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +impl TokenData { + pub fn new( + access_token: String, + refresh_token: String, + expires_in: i64, + email: Option, + project_id: Option, + session_id: Option, + ) -> Self { + let expiry_timestamp = chrono::Utc::now().timestamp() + expires_in; + Self { + access_token, + refresh_token, + expires_in, + expiry_timestamp, + token_type: "Bearer".to_string(), + email, + project_id, + session_id, + } + } +} diff --git a/server/src/modules/account.rs b/server/src/modules/account.rs new file mode 100644 index 0000000000000000000000000000000000000000..cba3cdde4b2fb044bbd27b9b32fe3816b0ced3c3 --- /dev/null +++ b/server/src/modules/account.rs @@ -0,0 +1,471 @@ +use std::fs; +use std::path::PathBuf; +use uuid::Uuid; + +use crate::models::{Account, AccountIndex, AccountSummary, TokenData, QuotaData}; +use crate::modules; +use once_cell::sync::Lazy; +use std::sync::Mutex; + +/// Global account write lock to prevent concurrent index file corruption +static ACCOUNT_INDEX_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +const DATA_DIR_LOCAL: &str = ".antigravity_tools"; +const DATA_DIR_CLOUD: &str = "/data"; +const ACCOUNTS_INDEX: &str = "accounts.json"; +const ACCOUNTS_DIR: &str = "accounts"; + +/// Get data directory path (cloud-compatible) +pub fn get_data_dir() -> Result { + // Check for cloud environment (/data) + let cloud_path = PathBuf::from(DATA_DIR_CLOUD); + if cloud_path.exists() { + return Ok(cloud_path); + } + + // Fallback to local development path + let home = dirs::home_dir().ok_or("Cannot get user home directory")?; + let data_dir = home.join(DATA_DIR_LOCAL); + + // Ensure directory exists + if !data_dir.exists() { + fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + } + + Ok(data_dir) +} + +/// Get accounts directory path +pub fn get_accounts_dir() -> Result { + let data_dir = get_data_dir()?; + let accounts_dir = data_dir.join(ACCOUNTS_DIR); + + if !accounts_dir.exists() { + fs::create_dir_all(&accounts_dir) + .map_err(|e| format!("Failed to create accounts directory: {}", e))?; + } + + Ok(accounts_dir) +} + +/// Load account index +pub fn load_account_index() -> Result { + let data_dir = get_data_dir()?; + let index_path = data_dir.join(ACCOUNTS_INDEX); + + if !index_path.exists() { + modules::logger::log_warn("Account index file does not exist"); + return Ok(AccountIndex::new()); + } + + let content = fs::read_to_string(&index_path) + .map_err(|e| format!("Failed to read account index: {}", e))?; + + let index: AccountIndex = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse account index: {}", e))?; + + modules::logger::log_info(&format!("Loaded index with {} accounts", index.accounts.len())); + Ok(index) +} + +/// Save account index (atomic write) +pub fn save_account_index(index: &AccountIndex) -> Result<(), String> { + let data_dir = get_data_dir()?; + let index_path = data_dir.join(ACCOUNTS_INDEX); + let temp_path = data_dir.join(format!("{}.tmp", ACCOUNTS_INDEX)); + + let content = serde_json::to_string_pretty(index) + .map_err(|e| format!("Failed to serialize account index: {}", e))?; + + // Write to temp file + fs::write(&temp_path, content) + .map_err(|e| format!("Failed to write temp index file: {}", e))?; + + // Atomic rename + fs::rename(temp_path, index_path) + .map_err(|e| format!("Failed to replace index file: {}", e)) +} + +/// Load account data +pub fn load_account(account_id: &str) -> Result { + let accounts_dir = get_accounts_dir()?; + let account_path = accounts_dir.join(format!("{}.json", account_id)); + + if !account_path.exists() { + return Err(format!("Account not found: {}", account_id)); + } + + let content = fs::read_to_string(&account_path) + .map_err(|e| format!("Failed to read account data: {}", e))?; + + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse account data: {}", e)) +} + +/// Save account data +pub fn save_account(account: &Account) -> Result<(), String> { + let accounts_dir = get_accounts_dir()?; + let account_path = accounts_dir.join(format!("{}.json", account.id)); + + let content = serde_json::to_string_pretty(account) + .map_err(|e| format!("Failed to serialize account data: {}", e))?; + + fs::write(&account_path, content) + .map_err(|e| format!("Failed to save account data: {}", e)) +} + +/// List all accounts +pub fn list_accounts() -> Result, String> { + modules::logger::log_info("Listing accounts..."); + let mut index = load_account_index()?; + let mut accounts = Vec::new(); + let mut invalid_ids = Vec::new(); + + for summary in &index.accounts { + match load_account(&summary.id) { + Ok(account) => accounts.push(account), + Err(e) => { + modules::logger::log_error(&format!("Failed to load account {}: {}", summary.id, e)); + if e.contains("Account not found") || e.contains("Os { code: 2,") || e.contains("No such file") { + invalid_ids.push(summary.id.clone()); + } + }, + } + } + + // Auto-fix index: remove invalid account IDs + if !invalid_ids.is_empty() { + modules::logger::log_warn(&format!("Found {} invalid account indexes, cleaning up...", invalid_ids.len())); + + index.accounts.retain(|s| !invalid_ids.contains(&s.id)); + + if let Some(current_id) = &index.current_account_id { + if invalid_ids.contains(current_id) { + index.current_account_id = index.accounts.first().map(|s| s.id.clone()); + } + } + + if let Err(e) = save_account_index(&index) { + modules::logger::log_error(&format!("Failed to clean up index: {}", e)); + } else { + modules::logger::log_info("Index cleanup completed"); + } + } + + Ok(accounts) +} + +/// Add account +pub fn add_account(email: String, name: Option, token: TokenData) -> Result { + let _lock = ACCOUNT_INDEX_LOCK.lock().map_err(|e| format!("Failed to get lock: {}", e))?; + let mut index = load_account_index()?; + + // Check if already exists + if index.accounts.iter().any(|s| s.email == email) { + return Err(format!("Account already exists: {}", email)); + } + + // Create new account + let account_id = Uuid::new_v4().to_string(); + let mut account = Account::new(account_id.clone(), email.clone(), token); + account.name = name.clone(); + + // Save account data + save_account(&account)?; + + // Update index + index.accounts.push(AccountSummary { + id: account_id.clone(), + email: email.clone(), + name: name.clone(), + created_at: account.created_at, + last_used: account.last_used, + }); + + // If first account, set as current + if index.current_account_id.is_none() { + index.current_account_id = Some(account_id); + } + + save_account_index(&index)?; + + Ok(account) +} + +/// Add or update account +pub fn upsert_account(email: String, name: Option, token: TokenData) -> Result { + let _lock = ACCOUNT_INDEX_LOCK.lock().map_err(|e| format!("Failed to get lock: {}", e))?; + let mut index = load_account_index()?; + + // Find account ID if exists + let existing_account_id = index.accounts.iter() + .find(|s| s.email == email) + .map(|s| s.id.clone()); + + if let Some(account_id) = existing_account_id { + // Update existing account + match load_account(&account_id) { + Ok(mut account) => { + account.token = token; + account.name = name.clone(); + account.update_last_used(); + save_account(&account)?; + + // Sync update name in index + if let Some(idx_summary) = index.accounts.iter_mut().find(|s| s.id == account_id) { + idx_summary.name = name; + save_account_index(&index)?; + } + + return Ok(account); + }, + Err(e) => { + modules::logger::log_warn(&format!("Account {} file missing ({}), recreating...", account_id, e)); + // Index exists but file missing, recreate + let mut account = Account::new(account_id.clone(), email.clone(), token); + account.name = name.clone(); + save_account(&account)?; + + if let Some(idx_summary) = index.accounts.iter_mut().find(|s| s.id == account_id) { + idx_summary.name = name; + save_account_index(&index)?; + } + + return Ok(account); + } + } + } + + // Not exists, add new + drop(_lock); + add_account(email, name, token) +} + +/// Delete account +pub fn delete_account(account_id: &str) -> Result<(), String> { + let _lock = ACCOUNT_INDEX_LOCK.lock().map_err(|e| format!("Failed to get lock: {}", e))?; + let mut index = load_account_index()?; + + // Remove from index + let original_len = index.accounts.len(); + index.accounts.retain(|s| s.id != account_id); + + if index.accounts.len() == original_len { + return Err(format!("Account ID not found: {}", account_id)); + } + + // If current account, clear it + if index.current_account_id.as_deref() == Some(account_id) { + index.current_account_id = index.accounts.first().map(|s| s.id.clone()); + } + + save_account_index(&index)?; + + // Delete account file + let accounts_dir = get_accounts_dir()?; + let account_path = accounts_dir.join(format!("{}.json", account_id)); + + if account_path.exists() { + fs::remove_file(&account_path) + .map_err(|e| format!("Failed to delete account file: {}", e))?; + } + + Ok(()) +} + +/// Batch delete accounts (atomic index operation) +pub fn delete_accounts(account_ids: &[String]) -> Result<(), String> { + let _lock = ACCOUNT_INDEX_LOCK.lock().map_err(|e| format!("Failed to get lock: {}", e))?; + let mut index = load_account_index()?; + + let accounts_dir = get_accounts_dir()?; + + for account_id in account_ids { + // Remove from index + index.accounts.retain(|s| &s.id != account_id); + + // If current account, clear it + if index.current_account_id.as_deref() == Some(account_id) { + index.current_account_id = None; + } + + // Delete account file + let account_path = accounts_dir.join(format!("{}.json", account_id)); + if account_path.exists() { + let _ = fs::remove_file(&account_path); + } + } + + // If current account is empty, try to select first as default + if index.current_account_id.is_none() { + index.current_account_id = index.accounts.first().map(|s| s.id.clone()); + } + + save_account_index(&index) +} + +/// Get current account ID +pub fn get_current_account_id() -> Result, String> { + let index = load_account_index()?; + Ok(index.current_account_id) +} + +/// Get current active account info +pub fn get_current_account() -> Result, String> { + if let Some(id) = get_current_account_id()? { + Ok(Some(load_account(&id)?)) + } else { + Ok(None) + } +} + +/// Set current active account ID +pub fn set_current_account_id(account_id: &str) -> Result<(), String> { + let _lock = ACCOUNT_INDEX_LOCK.lock().map_err(|e| format!("Failed to get lock: {}", e))?; + let mut index = load_account_index()?; + index.current_account_id = Some(account_id.to_string()); + save_account_index(&index) +} + +/// Update account quota +pub fn update_account_quota(account_id: &str, quota: QuotaData) -> Result<(), String> { + let mut account = load_account(account_id)?; + account.update_quota(quota); + save_account(&account) +} + +/// Export all account refresh_tokens +#[allow(dead_code)] +pub fn export_accounts() -> Result, String> { + let accounts = list_accounts()?; + let mut exports = Vec::new(); + + for account in accounts { + exports.push((account.email, account.token.refresh_token)); + } + + Ok(exports) +} + +/// Fetch quota with retry mechanism +pub async fn fetch_quota_with_retry(account: &mut Account) -> crate::error::AppResult { + use crate::modules::oauth; + use crate::error::AppError; + use reqwest::StatusCode; + + // 1. Time-based check - ensure token is valid + let token = oauth::ensure_fresh_token(&account.token).await.map_err(AppError::OAuth)?; + + if token.access_token != account.token.access_token { + modules::logger::log_info(&format!("Token refreshed for: {}", account.email)); + account.token = token.clone(); + + // Re-fetch user name if missing + let name = if account.name.is_none() || account.name.as_ref().map_or(false, |n| n.trim().is_empty()) { + match oauth::get_user_info(&token.access_token).await { + Ok(user_info) => user_info.get_display_name(), + Err(_) => None + } + } else { + account.name.clone() + }; + + account.name = name.clone(); + upsert_account(account.email.clone(), name, token.clone()).map_err(AppError::Account)?; + } + + // 0. Fill user name if missing + if account.name.is_none() || account.name.as_ref().map_or(false, |n| n.trim().is_empty()) { + modules::logger::log_info(&format!("Account {} missing name, fetching...", account.email)); + match oauth::get_user_info(&account.token.access_token).await { + Ok(user_info) => { + let display_name = user_info.get_display_name(); + modules::logger::log_info(&format!("Got user name: {:?}", display_name)); + account.name = display_name.clone(); + if let Err(e) = upsert_account(account.email.clone(), display_name, account.token.clone()) { + modules::logger::log_warn(&format!("Failed to save user name: {}", e)); + } + }, + Err(e) => { + modules::logger::log_warn(&format!("Failed to get user name: {}", e)); + } + } + } + + // 2. Try to query quota + let result: crate::error::AppResult<(QuotaData, Option)> = modules::fetch_quota(&account.token.access_token, &account.email).await; + + // Capture possible project_id update and save + if let Ok((ref _q, ref project_id)) = result { + if project_id.is_some() && *project_id != account.token.project_id { + modules::logger::log_info(&format!("Detected project_id update ({}), saving...", account.email)); + account.token.project_id = project_id.clone(); + if let Err(e) = upsert_account(account.email.clone(), account.name.clone(), account.token.clone()) { + modules::logger::log_warn(&format!("Failed to save project_id: {}", e)); + } + } + } + + // 3. Handle 401 error + if let Err(AppError::Network(ref e)) = result { + if let Some(status) = e.status() { + if status == StatusCode::UNAUTHORIZED { + modules::logger::log_warn(&format!("401 Unauthorized for {}, forcing refresh...", account.email)); + + // Force refresh + let token_res = oauth::refresh_access_token(&account.token.refresh_token) + .await + .map_err(AppError::OAuth)?; + + let new_token = TokenData::new( + token_res.access_token.clone(), + account.token.refresh_token.clone(), + token_res.expires_in, + account.token.email.clone(), + account.token.project_id.clone(), + None, + ); + + // Re-fetch user name + let name = if account.name.is_none() || account.name.as_ref().map_or(false, |n| n.trim().is_empty()) { + match oauth::get_user_info(&token_res.access_token).await { + Ok(user_info) => user_info.get_display_name(), + Err(_) => None + } + } else { + account.name.clone() + }; + + account.token = new_token.clone(); + account.name = name.clone(); + upsert_account(account.email.clone(), name, new_token.clone()).map_err(AppError::Account)?; + + // Retry query + let retry_result: crate::error::AppResult<(QuotaData, Option)> = modules::fetch_quota(&new_token.access_token, &account.email).await; + + // Also handle retry project_id save + if let Ok((ref _q, ref project_id)) = retry_result { + if project_id.is_some() && *project_id != account.token.project_id { + modules::logger::log_info(&format!("Detected retry project_id update ({}), saving...", account.email)); + account.token.project_id = project_id.clone(); + let _ = upsert_account(account.email.clone(), account.name.clone(), account.token.clone()); + } + } + + if let Err(AppError::Network(ref e)) = retry_result { + if let Some(s) = e.status() { + if s == StatusCode::FORBIDDEN { + let mut q = QuotaData::new(); + q.is_forbidden = true; + return Ok(q); + } + } + } + return retry_result.map(|(q, _)| q); + } + } + } + + result.map(|(q, _)| q) +} diff --git a/server/src/modules/config.rs b/server/src/modules/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..32ac1f893fbc11df434f7cb6956e9deefc4d6595 --- /dev/null +++ b/server/src/modules/config.rs @@ -0,0 +1,34 @@ +use std::fs; + +use crate::models::AppConfig; +use super::account::get_data_dir; + +const CONFIG_FILE: &str = "config.json"; + +/// Load application configuration +pub fn load_app_config() -> Result { + let data_dir = get_data_dir()?; + let config_path = data_dir.join(CONFIG_FILE); + + if !config_path.exists() { + return Ok(AppConfig::new()); + } + + let content = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config file: {}", e))?; + + serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse config file: {}", e)) +} + +/// Save application configuration +pub fn save_app_config(config: &AppConfig) -> Result<(), String> { + let data_dir = get_data_dir()?; + let config_path = data_dir.join(CONFIG_FILE); + + let content = serde_json::to_string_pretty(config) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + + fs::write(&config_path, content) + .map_err(|e| format!("Failed to save config: {}", e)) +} diff --git a/server/src/modules/logger.rs b/server/src/modules/logger.rs new file mode 100644 index 0000000000000000000000000000000000000000..6f4e5e93743f22ad1feaf15f22d3427a23ef83e9 --- /dev/null +++ b/server/src/modules/logger.rs @@ -0,0 +1,57 @@ +use tracing::{info, warn, error}; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +// Custom local timezone time formatter +struct LocalTimer; + +impl tracing_subscriber::fmt::time::FormatTime for LocalTimer { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { + let now = chrono::Local::now(); + write!(w, "{}", now.to_rfc3339()) + } +} + +/// Initialize logging system (stdout only for cloud deployment) +pub fn init_logger() { + // Capture log macro logs + let _ = tracing_log::LogTracer::init(); + + // Console output layer with local timezone + let console_layer = fmt::Layer::new() + .with_target(false) + .with_thread_ids(false) + .with_level(true) + .with_timer(LocalTimer); + + // Filter layer (default INFO and above) + let filter_layer = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")); + + // Initialize global subscriber + let _ = tracing_subscriber::registry() + .with(filter_layer) + .with(console_layer) + .try_init(); + + info!("Logger initialized (stdout only for cloud deployment)"); +} + +/// Log info message (backward compatible interface) +pub fn log_info(message: &str) { + info!("{}", message); +} + +/// Log warning message (backward compatible interface) +pub fn log_warn(message: &str) { + warn!("{}", message); +} + +/// Log error message (backward compatible interface) +pub fn log_error(message: &str) { + error!("{}", message); +} + +/// Clear logs (no-op for cloud deployment) +pub fn clear_logs() -> Result<(), String> { + Ok(()) +} diff --git a/server/src/modules/mod.rs b/server/src/modules/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..51b0eb114de6e6103101b3cc5aa720a6f44e3cca --- /dev/null +++ b/server/src/modules/mod.rs @@ -0,0 +1,17 @@ +pub mod account; +pub mod quota; +pub mod config; +pub mod logger; +pub mod oauth; + +use crate::models; + +// Re-export common functions to modules namespace +pub use account::*; +pub use quota::*; +pub use config::*; +pub use logger::*; + +pub async fn fetch_quota(access_token: &str, email: &str) -> crate::error::AppResult<(models::QuotaData, Option)> { + quota::fetch_quota(access_token, email).await +} diff --git a/server/src/modules/oauth.rs b/server/src/modules/oauth.rs new file mode 100644 index 0000000000000000000000000000000000000000..83b47da89dc3e71e79ceac6e8c37aea252a45ed3 --- /dev/null +++ b/server/src/modules/oauth.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +// Google OAuth configuration +const CLIENT_ID: &str = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; +const CLIENT_SECRET: &str = "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"; +const TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const USERINFO_URL: &str = "https://www.googleapis.com/oauth2/v2/userinfo"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub expires_in: i64, + #[serde(default)] + pub token_type: String, + #[serde(default)] + pub refresh_token: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserInfo { + pub email: String, + pub name: Option, + pub given_name: Option, + pub family_name: Option, + pub picture: Option, +} + +impl UserInfo { + /// Get best display name + pub fn get_display_name(&self) -> Option { + // Prefer name + if let Some(name) = &self.name { + if !name.trim().is_empty() { + return Some(name.clone()); + } + } + + // If name is empty, try combining given_name and family_name + match (&self.given_name, &self.family_name) { + (Some(given), Some(family)) => Some(format!("{} {}", given, family)), + (Some(given), None) => Some(given.clone()), + (None, Some(family)) => Some(family.clone()), + (None, None) => None, + } + } +} + +/// Refresh access_token using refresh_token +pub async fn refresh_access_token(refresh_token: &str) -> Result { + let client = crate::utils::http::create_client(15); + + let params = [ + ("client_id", CLIENT_ID), + ("client_secret", CLIENT_SECRET), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]; + + crate::modules::logger::log_info("Refreshing token..."); + + let response = client + .post(TOKEN_URL) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Refresh request failed: {}", e))?; + + if response.status().is_success() { + let token_data = response + .json::() + .await + .map_err(|e| format!("Failed to parse refresh data: {}", e))?; + + crate::modules::logger::log_info(&format!("Token refreshed! Valid for: {} seconds", token_data.expires_in)); + Ok(token_data) + } else { + let error_text = response.text().await.unwrap_or_default(); + Err(format!("Refresh failed: {}", error_text)) + } +} + +/// Get user info +pub async fn get_user_info(access_token: &str) -> Result { + let client = crate::utils::http::create_client(15); + + let response = client + .get(USERINFO_URL) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("User info request failed: {}", e))?; + + if response.status().is_success() { + response.json::() + .await + .map_err(|e| format!("Failed to parse user info: {}", e)) + } else { + let error_text = response.text().await.unwrap_or_default(); + Err(format!("Failed to get user info: {}", error_text)) + } +} + +/// Check and refresh token if needed +/// Returns the latest access_token +pub async fn ensure_fresh_token( + current_token: &crate::models::TokenData, +) -> Result { + let now = chrono::Local::now().timestamp(); + + // If no expiry time, or still has more than 5 minutes validity, return directly + if current_token.expiry_timestamp > now + 300 { + return Ok(current_token.clone()); + } + + // Need to refresh + crate::modules::logger::log_info("Token about to expire, refreshing..."); + let response = refresh_access_token(¤t_token.refresh_token).await?; + + // Construct new TokenData + Ok(crate::models::TokenData::new( + response.access_token, + current_token.refresh_token.clone(), // Refresh doesn't always return new refresh_token + response.expires_in, + current_token.email.clone(), + current_token.project_id.clone(), // Keep original project_id + None, // session_id will be generated in token_manager + )) +} diff --git a/server/src/modules/quota.rs b/server/src/modules/quota.rs new file mode 100644 index 0000000000000000000000000000000000000000..5b5fb782e584c6cb23fb3c0f85eb119cfa133993 --- /dev/null +++ b/server/src/modules/quota.rs @@ -0,0 +1,214 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use crate::models::QuotaData; + +const QUOTA_API_URL: &str = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; +const USER_AGENT: &str = "antigravity/1.11.3 Darwin/arm64"; +const CLOUD_CODE_BASE_URL: &str = "https://cloudcode-pa.googleapis.com"; + +#[derive(Debug, Serialize, Deserialize)] +struct QuotaResponse { + models: std::collections::HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ModelInfo { + #[serde(rename = "quotaInfo")] + quota_info: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct QuotaInfo { + #[serde(rename = "remainingFraction")] + remaining_fraction: Option, + #[serde(rename = "resetTime")] + reset_time: Option, +} + +#[derive(Debug, Deserialize)] +struct LoadProjectResponse { + #[serde(rename = "cloudaicompanionProject")] + project_id: Option, + #[serde(rename = "currentTier")] + current_tier: Option, + #[serde(rename = "paidTier")] + paid_tier: Option, +} + +#[derive(Debug, Deserialize)] +struct Tier { + id: Option, + #[serde(rename = "quotaTier")] + #[allow(dead_code)] + quota_tier: Option, + #[allow(dead_code)] + name: Option, + #[allow(dead_code)] + slug: Option, +} + +/// Create configured HTTP client +fn create_client() -> reqwest::Client { + crate::utils::http::create_client(15) +} + +/// Get project ID and subscription type +async fn fetch_project_id(access_token: &str, email: &str) -> (Option, Option) { + let client = create_client(); + let meta = json!({"metadata": {"ideType": "ANTIGRAVITY"}}); + + let res = client + .post(format!("{}/v1internal:loadCodeAssist", CLOUD_CODE_BASE_URL)) + .header(reqwest::header::AUTHORIZATION, format!("Bearer {}", access_token)) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::USER_AGENT, "antigravity/windows/amd64") + .json(&meta) + .send() + .await; + + match res { + Ok(res) => { + if res.status().is_success() { + if let Ok(data) = res.json::().await { + let project_id = data.project_id.clone(); + + // Core logic: prefer paid_tier ID, reflects true account rights better than current_tier + let subscription_tier = data.paid_tier + .and_then(|t| t.id) + .or_else(|| data.current_tier.and_then(|t| t.id)); + + if let Some(ref tier) = subscription_tier { + crate::modules::logger::log_info(&format!( + "[{}] Subscription identified: {}", email, tier + )); + } + + return (project_id, subscription_tier); + } + } else { + crate::modules::logger::log_warn(&format!( + "[{}] loadCodeAssist failed: Status: {}", email, res.status() + )); + } + } + Err(e) => { + crate::modules::logger::log_error(&format!("[{}] loadCodeAssist network error: {}", email, e)); + } + } + + (None, None) +} + +/// Unified entry point for querying account quota +pub async fn fetch_quota(access_token: &str, email: &str) -> crate::error::AppResult<(QuotaData, Option)> { + fetch_quota_inner(access_token, email).await +} + +/// Quota query logic +pub async fn fetch_quota_inner(access_token: &str, email: &str) -> crate::error::AppResult<(QuotaData, Option)> { + use crate::error::AppError; + + // 1. Get project ID and subscription type + let (project_id, subscription_tier) = fetch_project_id(access_token, email).await; + + let final_project_id = project_id.as_deref().unwrap_or("bamboo-precept-lgxtn"); + + let client = create_client(); + let payload = json!({ + "project": final_project_id + }); + + let url = QUOTA_API_URL; + let max_retries = 3; + let mut last_error: Option = None; + + for attempt in 1..=max_retries { + match client + .post(url) + .bearer_auth(access_token) + .header("User-Agent", USER_AGENT) + .json(&json!(payload)) + .send() + .await + { + Ok(response) => { + // Convert HTTP error status to AppError + if response.error_for_status_ref().is_err() { + let status = response.status(); + + // Special handling for 403 Forbidden - return directly, no retry + if status == reqwest::StatusCode::FORBIDDEN { + crate::modules::logger::log_warn("Account forbidden (403), marking as forbidden status"); + let mut q = QuotaData::new(); + q.is_forbidden = true; + q.subscription_tier = subscription_tier.clone(); + return Ok((q, project_id.clone())); + } + + // Other errors continue retry logic + if attempt < max_retries { + let text = response.text().await.unwrap_or_default(); + crate::modules::logger::log_warn(&format!("API error: {} - {} (attempt {}/{})", status, text, attempt, max_retries)); + last_error = Some(AppError::Unknown(format!("HTTP {} - {}", status, text))); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + continue; + } else { + let text = response.text().await.unwrap_or_default(); + return Err(AppError::Unknown(format!("API error: {} - {}", status, text))); + } + } + + let quota_response: QuotaResponse = response + .json() + .await + .map_err(AppError::Network)?; + + let mut quota_data = QuotaData::new(); + + tracing::debug!("Quota API returned {} models", quota_response.models.len()); + + for (name, info) in quota_response.models { + if let Some(quota_info) = info.quota_info { + let percentage = quota_info.remaining_fraction + .map(|f| (f * 100.0) as i32) + .unwrap_or(0); + + let reset_time = quota_info.reset_time.unwrap_or_default(); + + // Only save models we care about + if name.contains("gemini") || name.contains("claude") { + quota_data.add_model(name, percentage, reset_time); + } + } + } + + // Set subscription type + quota_data.subscription_tier = subscription_tier.clone(); + + return Ok((quota_data, project_id.clone())); + }, + Err(e) => { + crate::modules::logger::log_warn(&format!("Request failed: {} (attempt {}/{})", e, attempt, max_retries)); + last_error = Some(AppError::Network(e)); + if attempt < max_retries { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + } + } + + Err(last_error.unwrap_or_else(|| AppError::Unknown("Quota query failed".to_string()))) +} + +/// Batch query all account quotas (backup function) +#[allow(dead_code)] +pub async fn fetch_all_quotas(accounts: Vec<(String, String)>) -> Vec<(String, crate::error::AppResult)> { + let mut results = Vec::new(); + + for (account_id, access_token) in accounts { + let result = fetch_quota(&access_token, &account_id).await.map(|(q, _)| q); + results.push((account_id, result)); + } + + results +} diff --git a/server/src/proxy/common/error.rs b/server/src/proxy/common/error.rs new file mode 100644 index 0000000000000000000000000000000000000000..846883f2e3e5fda9394f53cc1374f8197a5e8111 --- /dev/null +++ b/server/src/proxy/common/error.rs @@ -0,0 +1,41 @@ +// 错误处理 +use thiserror::Error; +use axum::{http::StatusCode, Json, response::IntoResponse}; + +#[derive(Debug, Error)] +pub enum ProxyError { + #[error("Upstream API error: {0}")] + UpstreamError(String), + + #[error("Transform error: {0}")] + TransformError(String), + + #[error("Account error: {0}")] + AccountError(String), + + #[error("Rate limit exceeded")] + RateLimitExceeded, + + #[error("Invalid request: {0}")] + InvalidRequest(String), +} + +impl IntoResponse for ProxyError { + fn into_response(self) -> axum::response::Response { + let status = match &self { + ProxyError::InvalidRequest(_) => StatusCode::BAD_REQUEST, + ProxyError::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS, + ProxyError::AccountError(_) => StatusCode::UNAUTHORIZED, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + let body = serde_json::json!({ + "error": { + "message": self.to_string(), + "type": format!("{:?}", self) + } + }); + + (status, Json(body)).into_response() + } +} diff --git a/server/src/proxy/common/json_schema.rs b/server/src/proxy/common/json_schema.rs new file mode 100644 index 0000000000000000000000000000000000000000..c60c3cd2c40607efa9821241c7b6370e66d8ed5e --- /dev/null +++ b/server/src/proxy/common/json_schema.rs @@ -0,0 +1,252 @@ +use serde_json::Value; + +/// 递归清理 JSON Schema 以符合 Gemini 接口要求 +/// +/// 1. [New] 展开 $ref 和 $defs: 将引用替换为实际定义,解决 Gemini 不支持 $ref 的问题 +/// 2. 移除不支持的字段: $schema, additionalProperties, format, default, uniqueItems, validation fields +/// 3. 处理联合类型: ["string", "null"] -> "string" +/// 4. 将 type 字段的值转换为大写 (Gemini v1internal 要求) +/// 5. 移除数字校验字段: multipleOf, exclusiveMinimum, exclusiveMaximum 等 +pub fn clean_json_schema(value: &mut Value) { + // 0. 预处理:展开 $ref (Schema Flattening) + if let Value::Object(map) = value { + let mut defs = serde_json::Map::new(); + // 提取 $defs 或 definitions + if let Some(Value::Object(d)) = map.remove("$defs") { + defs.extend(d); + } + if let Some(Value::Object(d)) = map.remove("definitions") { + defs.extend(d); + } + + if !defs.is_empty() { + // 递归替换引用 + flatten_refs(map, &defs); + } + } + + // 递归清理 + clean_json_schema_recursive(value); +} + +/// 递归展开 $ref +fn flatten_refs(map: &mut serde_json::Map, defs: &serde_json::Map) { + // 检查并替换 $ref + if let Some(Value::String(ref_path)) = map.remove("$ref") { + // 解析引用名 (例如 #/$defs/MyType -> MyType) + let ref_name = ref_path.split('/').last().unwrap_or(&ref_path); + + if let Some(def_schema) = defs.get(ref_name) { + // 将定义的内容合并到当前 map + if let Value::Object(def_map) = def_schema { + for (k, v) in def_map { + // 仅当当前 map 没有该 key 时才插入 (避免覆盖) + // 但通常 $ref 节点不应该有其他属性 + map.entry(k.clone()).or_insert_with(|| v.clone()); + } + + // 递归处理刚刚合并进来的内容中可能包含的 $ref + // 注意:这里可能会无限递归如果存在循环引用,但工具定义通常是 DAG + flatten_refs(map, defs); + } + } + } + + // 遍历子节点 + for (_, v) in map.iter_mut() { + if let Value::Object(child_map) = v { + flatten_refs(child_map, defs); + } else if let Value::Array(arr) = v { + for item in arr { + if let Value::Object(item_map) = item { + flatten_refs(item_map, defs); + } + } + } + } +} + +fn clean_json_schema_recursive(value: &mut Value) { + match value { + Value::Object(map) => { + // 1. [CRITICAL] 深度递归处理:必须遍历当前对象的所有字段名对应的 Value + // 解决 properties/items 之外的 definitions、anyOf、allOf 等结构的清理 + for v in map.values_mut() { + clean_json_schema_recursive(v); + } + + // 2. 收集并处理校验字段 (Migration logic: 将约束降级为描述中的 Hint) + let mut constraints = Vec::new(); + + // 待迁移的约束黑名单 + let validation_fields = [ + ("pattern", "pattern"), + ("minLength", "minLen"), ("maxLength", "maxLen"), + ("minimum", "min"), ("maximum", "max"), + ("minItems", "minItems"), ("maxItems", "maxItems"), + ("exclusiveMinimum", "exclMin"), ("exclusiveMaximum", "exclMax"), + ("multipleOf", "multipleOf"), + ("format", "format"), + ]; + + for (field, label) in validation_fields { + if let Some(val) = map.remove(field) { + // 仅当值是简单类型时才迁移 + if val.is_string() || val.is_number() || val.is_boolean() { + constraints.push(format!("{}: {}", label, val)); + } + } + } + + // 3. 将约束信息追加到描述 + if !constraints.is_empty() { + let suffix = format!(" [Constraint: {}]", constraints.join(", ")); + let desc_val = map.entry("description".to_string()).or_insert_with(|| Value::String("".to_string())); + if let Value::String(s) = desc_val { + s.push_str(&suffix); + } + } + + // 4. 彻底物理移除干扰生成的“硬项”黑色名单 (Hard Blacklist) + let hard_remove_fields = [ + "$schema", + "additionalProperties", + "enumCaseInsensitive", + "enumNormalizeWhitespace", + "uniqueItems", + "default", + "const", + "examples", + "propertyNames", + "anyOf", "oneOf", "allOf", "not", + "if", "then", "else", + "dependencies", "dependentSchemas", "dependentRequired", + "cache_control", + ]; + for field in hard_remove_fields { + map.remove(field); + } + + // 5. 处理 type 字段 (Gemini 要求单字符串且小写) + if let Some(type_val) = map.get_mut("type") { + match type_val { + Value::String(s) => { + *type_val = Value::String(s.to_lowercase()); + } + Value::Array(arr) => { + let mut selected_type = "string".to_string(); + for item in arr { + if let Value::String(s) = item { + if s != "null" { + selected_type = s.to_lowercase(); + break; + } + } + } + *type_val = Value::String(selected_type); + } + _ => {} + } + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + clean_json_schema_recursive(v); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_clean_json_schema_draft_2020_12() { + let mut schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "location": { + "type": "string", + "minLength": 1, + "format": "city" + }, + // 模拟属性名冲突:pattern 是一个 Object 属性,不应被移除 + "pattern": { + "type": "object", + "properties": { + "regex": { "type": "string", "pattern": "^[a-z]+$" } + } + }, + "unit": { + "type": ["string", "null"], + "default": "celsius" + } + }, + "required": ["location"] + }); + + clean_json_schema(&mut schema); + + // 1. 验证类型保持小写 + assert_eq!(schema["type"], "object"); + assert_eq!(schema["properties"]["location"]["type"], "string"); + + // 2. 验证标准字段被转换并移动到描述 (Advanced Soft-Remove) + assert!(schema["properties"]["location"].get("minLength").is_none()); + assert!(schema["properties"]["location"]["description"].as_str().unwrap().contains("minLen: 1")); + + // 3. 验证名为 "pattern" 的属性未被误删 + assert!(schema["properties"].get("pattern").is_some()); + assert_eq!(schema["properties"]["pattern"]["type"], "object"); + + // 4. 验证内部的 pattern 校验字段被正确移除并转为描述 + assert!(schema["properties"]["pattern"]["properties"]["regex"].get("pattern").is_none()); + assert!(schema["properties"]["pattern"]["properties"]["regex"]["description"].as_str().unwrap().contains("pattern: ^[a-z]+$")); + + // 5. 验证联合类型被降级为单一类型 (Protobuf 兼容性) + assert_eq!(schema["properties"]["unit"]["type"], "string"); + + // 6. 验证元数据字段被移除 + assert!(schema.get("$schema").is_none()); + } + + #[test] + fn test_type_fallback() { + // Test ["string", "null"] -> "string" + let mut s1 = json!({"type": ["string", "null"]}); + clean_json_schema(&mut s1); + assert_eq!(s1["type"], "string"); + + // Test ["integer", "null"] -> "integer" (and lowercase check if needed, though usually integer) + let mut s2 = json!({"type": ["integer", "null"]}); + clean_json_schema(&mut s2); + assert_eq!(s2["type"], "integer"); + } + + #[test] + fn test_flatten_refs() { + let mut schema = json!({ + "$defs": { + "Address": { + "type": "object", + "properties": { + "city": { "type": "string" } + } + } + }, + "properties": { + "home": { "$ref": "#/$defs/Address" } + } + }); + + clean_json_schema(&mut schema); + + // 验证引用被展开且类型转为小写 + assert_eq!(schema["properties"]["home"]["type"], "object"); + assert_eq!(schema["properties"]["home"]["properties"]["city"]["type"], "string"); + } +} diff --git a/server/src/proxy/common/mod.rs b/server/src/proxy/common/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..ccac3a22dd008b1feec1aad088ce1deeda1fc997 --- /dev/null +++ b/server/src/proxy/common/mod.rs @@ -0,0 +1,7 @@ +// Common 模块 - 公共工具 + +// pub mod error; +// pub mod rate_limiter; +pub mod model_mapping; +pub mod utils; +pub mod json_schema; diff --git a/server/src/proxy/common/model_mapping.rs b/server/src/proxy/common/model_mapping.rs new file mode 100644 index 0000000000000000000000000000000000000000..9cfee6edd1db10bd0bc1b6b460cb2122388f89a0 --- /dev/null +++ b/server/src/proxy/common/model_mapping.rs @@ -0,0 +1,167 @@ +// 模型名称映射 +use std::collections::HashMap; +use once_cell::sync::Lazy; + +static CLAUDE_TO_GEMINI: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + + // 直接支持的模型 + m.insert("claude-opus-4-5-thinking", "claude-opus-4-5-thinking"); + m.insert("claude-sonnet-4-5", "claude-sonnet-4-5"); + m.insert("claude-sonnet-4-5-thinking", "claude-sonnet-4-5-thinking"); + + // 别名映射 + m.insert("claude-sonnet-4-5-20250929", "claude-sonnet-4-5-thinking"); + m.insert("claude-3-5-sonnet-20241022", "claude-sonnet-4-5"); + m.insert("claude-3-5-sonnet-20240620", "claude-sonnet-4-5"); + m.insert("claude-opus-4", "claude-opus-4-5-thinking"); + m.insert("claude-opus-4-5-20251101", "claude-opus-4-5-thinking"); + m.insert("claude-haiku-4", "claude-sonnet-4-5"); + m.insert("claude-3-haiku-20240307", "claude-sonnet-4-5"); + m.insert("claude-haiku-4-5-20251001", "claude-sonnet-4-5"); + // OpenAI 协议映射表 + m.insert("gpt-4", "gemini-2.5-pro"); + m.insert("gpt-4-turbo", "gemini-2.5-pro"); + m.insert("gpt-4-turbo-preview", "gemini-2.5-pro"); + m.insert("gpt-4-0125-preview", "gemini-2.5-pro"); + m.insert("gpt-4-1106-preview", "gemini-2.5-pro"); + m.insert("gpt-4-0613", "gemini-2.5-pro"); + + m.insert("gpt-4o", "gemini-2.5-pro"); + m.insert("gpt-4o-2024-05-13", "gemini-2.5-pro"); + m.insert("gpt-4o-2024-08-06", "gemini-2.5-pro"); + + m.insert("gpt-4o-mini", "gemini-2.5-flash"); + m.insert("gpt-4o-mini-2024-07-18", "gemini-2.5-flash"); + + m.insert("gpt-3.5-turbo", "gemini-2.5-flash"); + m.insert("gpt-3.5-turbo-16k", "gemini-2.5-flash"); + m.insert("gpt-3.5-turbo-0125", "gemini-2.5-flash"); + m.insert("gpt-3.5-turbo-1106", "gemini-2.5-flash"); + m.insert("gpt-3.5-turbo-0613", "gemini-2.5-flash"); + + // Gemini 协议映射表 + m.insert("gemini-2.5-flash-lite", "gemini-2.5-flash-lite"); + m.insert("gemini-2.5-flash-thinking", "gemini-2.5-flash-thinking"); + m.insert("gemini-3-pro-low", "gemini-3-pro-low"); + m.insert("gemini-3-pro-high", "gemini-3-pro-high"); + m.insert("gemini-3-pro-preview", "gemini-3-pro-preview"); + m.insert("gemini-2.5-flash", "gemini-2.5-flash"); + m.insert("gemini-3-flash", "gemini-3-flash"); + m.insert("gemini-3-pro-image", "gemini-3-pro-image"); + + m +}); + +pub fn map_claude_model_to_gemini(input: &str) -> String { + // 1. Check exact match in map + if let Some(mapped) = CLAUDE_TO_GEMINI.get(input) { + return mapped.to_string(); + } + + // 2. Pass-through known prefixes (gemini-, -thinking) to support dynamic suffixes + if input.starts_with("gemini-") || input.contains("thinking") { + return input.to_string(); + } + + // 3. Fallback to default + "claude-sonnet-4-5".to_string() +} + +/// 核心模型路由解析引擎 +/// 优先级:Custom Mapping (精确) > Group Mapping (家族) > System Mapping (内置插件) +pub fn resolve_model_route( + original_model: &str, + custom_mapping: &std::collections::HashMap, + openai_mapping: &std::collections::HashMap, + anthropic_mapping: &std::collections::HashMap, +) -> String { + // 1. 检查自定义精确映射 (优先级最高) + if let Some(target) = custom_mapping.get(original_model) { + crate::modules::logger::log_info(&format!("[Router] 使用自定义精确映射: {} -> {}", original_model, target)); + return target.clone(); + } + + let lower_model = original_model.to_lowercase(); + + // 2. 检查家族分组映射 (OpenAI 系) + // GPT-4 系列 (含 GPT-4 经典, o1, o3 等, 排除 4o/mini/turbo) + if (lower_model.starts_with("gpt-4") && !lower_model.contains("o") && !lower_model.contains("mini") && !lower_model.contains("turbo")) || + lower_model.starts_with("o1-") || lower_model.starts_with("o3-") || lower_model == "gpt-4" { + if let Some(target) = openai_mapping.get("gpt-4-series") { + crate::modules::logger::log_info(&format!("[Router] 使用 GPT-4 系列映射: {} -> {}", original_model, target)); + return target.clone(); + } + } + + // GPT-4o / 3.5 系列 (均衡与轻量, 含 4o, mini, turbo) + if lower_model.contains("4o") || lower_model.starts_with("gpt-3.5") || (lower_model.contains("mini") && !lower_model.contains("gemini")) || lower_model.contains("turbo") { + if let Some(target) = openai_mapping.get("gpt-4o-series") { + crate::modules::logger::log_info(&format!("[Router] 使用 GPT-4o/3.5 系列映射: {} -> {}", original_model, target)); + return target.clone(); + } + } + + // GPT-5 系列 (gpt-5, gpt-5.1, gpt-5.2 等) + if lower_model.starts_with("gpt-5") { + // 优先使用 gpt-5-series 映射,如果没有则使用 gpt-4-series + if let Some(target) = openai_mapping.get("gpt-5-series") { + crate::modules::logger::log_info(&format!("[Router] 使用 GPT-5 系列映射: {} -> {}", original_model, target)); + return target.clone(); + } + if let Some(target) = openai_mapping.get("gpt-4-series") { + crate::modules::logger::log_info(&format!("[Router] 使用 GPT-4 系列映射 (GPT-5 fallback): {} -> {}", original_model, target)); + return target.clone(); + } + } + + // 3. 检查家族分组映射 (Anthropic 系) + if lower_model.starts_with("claude-") { + let family_key = if lower_model.contains("4-5") || lower_model.contains("4.5") { + "claude-4.5-series" + } else if lower_model.contains("3-5") || lower_model.contains("3.5") { + "claude-3.5-series" + } else { + "claude-default" + }; + + if let Some(target) = anthropic_mapping.get(family_key) { + crate::modules::logger::log_warn(&format!("[Router] 使用 Anthropic 系列映射: {} -> {}", original_model, target)); + return target.clone(); + } + + // 兜底兼容旧版精确映射 + if let Some(target) = anthropic_mapping.get(original_model) { + return target.clone(); + } + } + + // 4. 下沉到系统默认映射逻辑 + map_claude_model_to_gemini(original_model) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_model_mapping() { + assert_eq!( + map_claude_model_to_gemini("claude-3-5-sonnet-20241022"), + "claude-sonnet-4-5" + ); + assert_eq!( + map_claude_model_to_gemini("claude-opus-4"), + "claude-opus-4-5-thinking" + ); + // Test gemini pass-through (should not be caught by "mini" rule) + assert_eq!( + map_claude_model_to_gemini("gemini-2.5-flash-mini-test"), + "gemini-2.5-flash-mini-test" + ); + assert_eq!( + map_claude_model_to_gemini("unknown-model"), + "claude-sonnet-4-5" + ); + } +} diff --git a/server/src/proxy/common/rate_limiter.rs b/server/src/proxy/common/rate_limiter.rs new file mode 100644 index 0000000000000000000000000000000000000000..c768e5fa0647367e666c6a39f3b2c184b6412c60 --- /dev/null +++ b/server/src/proxy/common/rate_limiter.rs @@ -0,0 +1,51 @@ +// Rate Limiter +// 确保 API 调用间隔 ≥ 500ms + +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio::time::{sleep, Duration, Instant}; + +pub struct RateLimiter { + min_interval: Duration, + last_call: Arc>>, +} + +impl RateLimiter { + pub fn new(min_interval_ms: u64) -> Self { + Self { + min_interval: Duration::from_millis(min_interval_ms), + last_call: Arc::new(Mutex::new(None)), + } + } + + pub async fn wait(&self) { + let mut last = self.last_call.lock().await; + if let Some(last_time) = *last { + let elapsed = last_time.elapsed(); + if elapsed < self.min_interval { + sleep(self.min_interval - elapsed).await; + } + } + *last = Some(Instant::now()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::Instant; + + #[tokio::test] + async fn test_rate_limiter() { + let limiter = RateLimiter::new(500); + let start = Instant::now(); + + limiter.wait().await; // 第一次调用,立即返回 + let elapsed1 = start.elapsed().as_millis(); + assert!(elapsed1 < 50); + + limiter.wait().await; // 第二次调用,等待 500ms + let elapsed2 = start.elapsed().as_millis(); + assert!(elapsed2 >= 500 && elapsed2 < 600); + } +} diff --git a/server/src/proxy/common/utils.rs b/server/src/proxy/common/utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..25f44d17b7c814479d03d59d0b6b7d14315bc4b5 --- /dev/null +++ b/server/src/proxy/common/utils.rs @@ -0,0 +1,20 @@ +// 工具函数 + +pub fn generate_random_id() -> String { + use rand::Rng; + rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(8) + .map(char::from) + .collect() +} + +/// 根据模型名称推测功能类型 +// 注意:此函数已弃用,请改用 mappers::common_utils::resolve_request_config +pub fn _deprecated_infer_quota_group(model: &str) -> String { + if model.to_lowercase().starts_with("claude") { + "claude".to_string() + } else { + "gemini".to_string() + } +} diff --git a/server/src/proxy/config.rs b/server/src/proxy/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..6ee58b830d74c3bb81d44361bed166189f3cb537 --- /dev/null +++ b/server/src/proxy/config.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; + +/// Proxy service configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyConfig { + /// Whether proxy service is enabled + pub enabled: bool, + + /// Whether to allow LAN access + /// - false: localhost only 127.0.0.1 (default, privacy first) + /// - true: allow LAN access 0.0.0.0 + #[serde(default)] + pub allow_lan_access: bool, + + /// Listen port + pub port: u16, + + /// API key + pub api_key: String, + + /// Whether to auto start + pub auto_start: bool, + + /// Anthropic model mapping (key: Claude model name, value: Gemini model name) + #[serde(default)] + pub anthropic_mapping: std::collections::HashMap, + + /// OpenAI model mapping (key: OpenAI model group, value: Gemini model name) + #[serde(default)] + pub openai_mapping: std::collections::HashMap, + + /// Custom exact model mapping (key: original model name, value: target model name) + #[serde(default)] + pub custom_mapping: std::collections::HashMap, + + /// API request timeout in seconds + #[serde(default = "default_request_timeout")] + pub request_timeout: u64, + + /// Upstream proxy configuration + #[serde(default)] + pub upstream_proxy: UpstreamProxyConfig, +} + +/// Upstream proxy configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UpstreamProxyConfig { + /// Whether enabled + pub enabled: bool, + /// Proxy URL (http://, https://, socks5://) + pub url: String, +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { + enabled: false, + allow_lan_access: true, // Cloud deployment should allow external access + port: 7860, // HuggingFace Spaces default port + api_key: format!("sk-{}", uuid::Uuid::new_v4().simple()), + auto_start: true, // Auto start in cloud environment + anthropic_mapping: std::collections::HashMap::new(), + openai_mapping: std::collections::HashMap::new(), + custom_mapping: std::collections::HashMap::new(), + request_timeout: default_request_timeout(), + upstream_proxy: UpstreamProxyConfig::default(), + } + } +} + +fn default_request_timeout() -> u64 { + 120 // Default 120 seconds +} + +impl ProxyConfig { + /// Get actual bind address + /// - allow_lan_access = false: return "127.0.0.1" (default, privacy first) + /// - allow_lan_access = true: return "0.0.0.0" (allow LAN access) + pub fn get_bind_address(&self) -> &str { + if self.allow_lan_access { + "0.0.0.0" + } else { + "127.0.0.1" + } + } +} diff --git a/server/src/proxy/handlers/claude.rs b/server/src/proxy/handlers/claude.rs new file mode 100644 index 0000000000000000000000000000000000000000..c08b6b48bfdcb46627c82f417ca241201db4fad5 --- /dev/null +++ b/server/src/proxy/handlers/claude.rs @@ -0,0 +1,475 @@ +// Claude 协议处理器 + +use axum::{ + body::Body, + extract::{Json, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use futures::StreamExt; +use serde_json::{json, Value}; +use tokio::time::{sleep, Duration}; +use tracing::{debug, error}; + +use crate::proxy::mappers::claude::{ + transform_claude_request_in, transform_response, create_claude_sse_stream, ClaudeRequest, +}; +use crate::proxy::server::AppState; + +const MAX_RETRY_ATTEMPTS: usize = 3; + +/// 处理 Claude messages 请求 +/// +/// 处理 Chat 消息请求流程 +pub async fn handle_messages( + State(state): State, + Json(request): Json, +) -> Response { + // 生成随机 Trace ID 用户追踪 + let trace_id: String = rand::Rng::sample_iter(rand::thread_rng(), &rand::distributions::Alphanumeric) + .take(6) + .map(char::from) + .collect::().to_lowercase(); + // 获取最新一条“有意义”的消息内容(用于日志记录和后台任务检测) + // 策略:反向遍历,首先筛选出所有角色为 "user" 的消息,然后从中找到第一条非 "Warmup" 且非空的文本消息 + // 获取最新一条“有意义”的消息内容(用于日志记录和后台任务检测) + // 策略:反向遍历,首先筛选出所有和用户相关的消息 (role="user") + // 然后提取其文本内容,跳过 "Warmup" 或系统预设的 reminder + let meaningful_msg = request.messages.iter().rev() + .filter(|m| m.role == "user") + .find_map(|m| { + let content = match &m.content { + crate::proxy::mappers::claude::models::MessageContent::String(s) => s.to_string(), + crate::proxy::mappers::claude::models::MessageContent::Array(arr) => { + // 对于数组,提取所有 Text 块并拼接,忽略 ToolResult + arr.iter() + .filter_map(|block| match block { + crate::proxy::mappers::claude::models::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" ") + } + }; + + // 过滤规则: + // 1. 忽略空消息 + // 2. 忽略 "Warmup" 消息 + // 3. 忽略 标签的消息 + if content.trim().is_empty() + || content.starts_with("Warmup") + || content.contains("") + { + None + } else { + Some(content) + } + }); + + // 如果经过过滤还是找不到(例如纯工具调用),则回退到最后一条消息的原始展示 + let latest_msg = meaningful_msg.unwrap_or_else(|| { + request.messages.last().map(|m| { + match &m.content { + crate::proxy::mappers::claude::models::MessageContent::String(s) => s.clone(), + crate::proxy::mappers::claude::models::MessageContent::Array(_) => "[Complex/Tool Message]".to_string() + } + }).unwrap_or_else(|| "[No Messages]".to_string()) + }); + + + crate::modules::logger::log_info(&format!("[{}] Received Claude request for model: {}, content_preview: {:.100}...", trace_id, request.model, latest_msg)); + tracing::info!("[{}] Full Claude Request: {}", trace_id, serde_json::to_string_pretty(&request).unwrap_or_default()); + + // 1. 获取 会话 ID (已废弃基于内容的哈希,改用 TokenManager 内部的时间窗口锁定) + let session_id: Option<&str> = None; + + // 2. 获取 UpstreamClient + let upstream = state.upstream.clone(); + + // 3. 准备闭包 + let mut request_for_body = request.clone(); + let token_manager = state.token_manager; + + let pool_size = token_manager.len(); + let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size).max(1); + + let mut last_error = String::new(); + let mut retried_without_thinking = false; + + for attempt in 0..max_attempts { + // 3. 模型路由与配置解析 (提前解析以确定请求类型) + let mut mapped_model = crate::proxy::common::model_mapping::resolve_model_route( + &request_for_body.model, + &*state.custom_mapping.read().await, + &*state.openai_mapping.read().await, + &*state.anthropic_mapping.read().await, + ); + // 将 Claude 工具转为 Value 数组以便探测联网 + let tools_val: Option> = request_for_body.tools.as_ref().map(|list| { + list.iter().map(|t| serde_json::to_value(t).unwrap_or(json!({}))).collect() + }); + + let config = crate::proxy::mappers::common_utils::resolve_request_config(&request_for_body.model, &mapped_model, &tools_val); + + // 4. 获取 Token (使用准确的 request_type) + // 关键:在重试尝试 (attempt > 0) 时,必须根据错误类型决定是否强制轮换账号 + let force_rotate_token = attempt > 0; + + let (access_token, project_id, email) = match token_manager.get_token(&config.request_type, force_rotate_token).await { + Ok(t) => t, + Err(e) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "type": "error", + "error": { + "type": "overloaded_error", + "message": format!("No available accounts: {}", e) + } + })) + ).into_response(); + } + }; + + tracing::info!("Using account: {} for request (type: {})", email, config.request_type); + + + // --- 核心优化:智能识别与拦截后台自动请求 --- + // [DEBUG] 临时调试:打印原始消息以诊断提取失败 + if let Some(last_msg) = request_for_body.messages.last() { + tracing::debug!("[{}] DEBUG - Last message role: {}, content type: {}", + trace_id, + last_msg.role, + match &last_msg.content { + crate::proxy::mappers::claude::models::MessageContent::String(_) => "String", + crate::proxy::mappers::claude::models::MessageContent::Array(_) => "Array", + } + ); + } + + // [FIX] 只扫描真正的"最后一条"用户消息,且必须过滤掉系统消息 + // 关键:复用 meaningful_msg 的过滤逻辑,确保 Warmup/system-reminder 不会被当作用户请求 + let last_user_msg = request_for_body.messages.iter().rev() + .filter(|m| m.role == "user") + .find_map(|m| { + let content = match &m.content { + crate::proxy::mappers::claude::models::MessageContent::String(s) => s.to_string(), + crate::proxy::mappers::claude::models::MessageContent::Array(arr) => { + arr.iter() + .filter_map(|block| match block { + crate::proxy::mappers::claude::models::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" ") + } + }; + + // 过滤规则:忽略系统消息 + if content.trim().is_empty() + || content.starts_with("Warmup") + || content.contains("") + { + None + } else { + Some(content) + } + }) + .unwrap_or_default(); + + // [DEBUG] 打印提取结果 + tracing::debug!("[{}] DEBUG - Extracted last_user_msg length: {}, preview: {:.100}", + trace_id, + last_user_msg.len(), + last_user_msg + ); + + // 关键词识别:标题生成、摘要提取、下一步提示建议等 + // [Optimization] 增加长度限制:真实用户提问通常不会包含这些特殊指令,且后台任务通常极短 + let preview_msg = last_user_msg.chars().take(500).collect::(); + + // [CRITICAL FIX] 强制识别系统消息为后台任务,防止它们消耗顶配额度 + let is_system_message = preview_msg.starts_with("Warmup") + || preview_msg.contains("") + || preview_msg.contains("Caveat: The messages below were generated by the user while running local commands"); + + let is_background_task = is_system_message || ( + (preview_msg.contains("write a 5-10 word title") + || preview_msg.contains("Respond with the title") + || preview_msg.contains("Concise summary") + || preview_msg.contains("prompt suggestion generator")) + && last_user_msg.len() < 800 + ); // 额外保险:后台任务通常不超过 800 字符 + + // 传递映射后的模型名 + let mut request_with_mapped = request_for_body.clone(); + + if is_background_task { + mapped_model = "gemini-2.5-flash".to_string(); + tracing::info!("[{}][AUTO] 检测到后台任务 ({}),已重定向: {}", + trace_id, + preview_msg, + mapped_model + ); + // [Optimization] **后台任务净化**: + // 此类任务纯粹为文本处理,绝不需要执行工具。 + // 强制清空 tools 字段,彻底根除 "Multiple tools" (400) 冲突风险。 + request_with_mapped.tools = None; + } else { + // [USER] 标记真实用户请求 + // [Optimization] 使用 WARN 级别高亮显示用户消息,防止被后台任务日志淹没 + tracing::warn!("[{}][USER] 检测到用户交互请求 ({:.100}),保持原模型: {}", + trace_id, + preview_msg, + mapped_model + ); + } + + + request_with_mapped.model = mapped_model; + + // 生成 Trace ID (简单用时间戳后缀) + // let _trace_id = format!("req_{}", chrono::Utc::now().timestamp_subsec_millis()); + + let gemini_body = match transform_claude_request_in(&request_with_mapped, &project_id) { + Ok(b) => { + tracing::info!("[{}] Transformed Gemini Body: {}", trace_id, serde_json::to_string_pretty(&b).unwrap_or_default()); + b + }, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "type": "error", + "error": { + "type": "api_error", + "message": format!("Transform error: {}", e) + } + })) + ).into_response(); + } + }; + + // 4. 上游调用 + let is_stream = request.stream; + let method = if is_stream { "streamGenerateContent" } else { "generateContent" }; + let query = if is_stream { Some("alt=sse") } else { None }; + + let response = match upstream.call_v1_internal( + method, + &access_token, + gemini_body, + query + ).await { + Ok(r) => r, + Err(e) => { + last_error = e.clone(); + tracing::warn!("Request failed on attempt {}/{}: {}", attempt + 1, max_attempts, e); + continue; + } + }; + + let status = response.status(); + + // 成功 + if status.is_success() { + // 处理流式响应 + if request.stream { + let stream = response.bytes_stream(); + let gemini_stream = Box::pin(stream); + let claude_stream = create_claude_sse_stream(gemini_stream, trace_id); + + // 转换为 Bytes stream + let sse_stream = claude_stream.map(|result| -> Result { + match result { + Ok(bytes) => Ok(bytes), + Err(e) => Ok(Bytes::from(format!("data: {{\"error\":\"{}\"}}\n\n", e))), + } + }); + + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .body(Body::from_stream(sse_stream)) + .unwrap(); + } else { + // 处理非流式响应 + let bytes = match response.bytes().await { + Ok(b) => b, + Err(e) => return (StatusCode::BAD_GATEWAY, format!("Failed to read body: {}", e)).into_response(), + }; + + // Debug print + if let Ok(text) = String::from_utf8(bytes.to_vec()) { + debug!("Upstream Response for Claude request: {}", text); + } + + let gemini_resp: Value = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)).into_response(), + }; + + // 解包 response 字段(v1internal 格式) + let raw = gemini_resp.get("response").unwrap_or(&gemini_resp); + + // 转换为 Gemini Response 结构 + let gemini_response: crate::proxy::mappers::claude::models::GeminiResponse = match serde_json::from_value(raw.clone()) { + Ok(r) => r, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Convert error: {}", e)).into_response(), + }; + + // 转换 + let claude_response = match transform_response(&gemini_response) { + Ok(r) => r, + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Transform error: {}", e)).into_response(), + }; + + // [Optimization] 记录闭环日志:消耗情况 + tracing::info!( + "[{}] Request finished. Model: {}, Tokens: In {}, Out {}", + trace_id, + request_with_mapped.model, + claude_response.usage.input_tokens, + claude_response.usage.output_tokens + ); + + return Json(claude_response).into_response(); + } + } + + // 处理错误 + let error_text = response.text().await.unwrap_or_else(|_| format!("HTTP {}", status)); + last_error = format!("HTTP {}: {}", status, error_text); + tracing::error!("[{}] Upstream Error Response: {}", trace_id, error_text); + + let status_code = status.as_u16(); + + // Handle transient 429s using upstream-provided retry delay (avoid surfacing errors to clients). + if status_code == 429 { + if let Some(delay_ms) = crate::proxy::upstream::retry::parse_retry_delay(&error_text) { + let actual_delay = delay_ms.saturating_add(200).min(10_000); + tracing::warn!( + "Claude Upstream 429 on attempt {}/{}, waiting {}ms then retrying", + attempt + 1, + max_attempts, + actual_delay + ); + sleep(Duration::from_millis(actual_delay)).await; + continue; + } + } + + // Special-case 400 errors caused by invalid/foreign thinking signatures (common after /resume). + // Retry once by stripping thinking blocks & thinking config from the request, and by disabling + // the "-thinking" model variant if present. + if status_code == 400 + && !retried_without_thinking + && (error_text.contains("Invalid `signature`") + || error_text.contains("thinking.signature: Field required") + || error_text.contains("thinking.thinking: Field required") + || error_text.contains("thinking.signature") + || error_text.contains("thinking.thinking")) + { + retried_without_thinking = true; + tracing::warn!("Upstream rejected thinking signature; retrying once with thinking stripped"); + + // 1) Remove thinking config + request_for_body.thinking = None; + + // 2) Remove thinking blocks from message history + for msg in request_for_body.messages.iter_mut() { + if let crate::proxy::mappers::claude::models::MessageContent::Array(blocks) = &mut msg.content { + blocks.retain(|b| !matches!(b, crate::proxy::mappers::claude::models::ContentBlock::Thinking { .. })); + } + } + + // 3) Prefer non-thinking Claude model variant on retry (best-effort) + if request_for_body.model.contains("claude-") { + let mut m = request_for_body.model.clone(); + m = m.replace("-thinking", ""); + // If it's a dated alias, fall back to a stable non-thinking id + if m.contains("claude-sonnet-4-5-") { + m = "claude-sonnet-4-5".to_string(); + } else if m.contains("claude-opus-4-5-") || m.contains("claude-opus-4-") { + m = "claude-opus-4-5".to_string(); + } + request_for_body.model = m; + } + + continue; + } + + // 只有 429 (限流), 403 (权限/地区限制) 和 401 (认证失效) 触发账号轮换 + if status_code == 429 || status_code == 403 || status_code == 401 { + // 如果是 429 且标记为配额耗尽(明确),直接报错,避免穿透整个账号池 + if status_code == 429 && error_text.contains("QUOTA_EXHAUSTED") { + error!("Claude Quota exhausted (429) on attempt {}/{}, stopping to protect pool.", attempt + 1, max_attempts); + return (status, error_text).into_response(); + } + + tracing::warn!("Claude Upstream {} on attempt {}/{}, rotating account", status, attempt + 1, max_attempts); + continue; + } + + // 404 等由于模型配置或路径错误的 HTTP 异常,直接报错,不进行无效轮换 + error!("Claude Upstream non-retryable error {}: {}", status_code, error_text); + return (status, error_text).into_response(); + } + + (StatusCode::TOO_MANY_REQUESTS, Json(json!({ + "type": "error", + "error": { + "type": "overloaded_error", + "message": format!("All {} attempts failed. Last error: {}", max_attempts, last_error) + } + }))).into_response() +} + +/// 列出可用模型 +pub async fn handle_list_models() -> impl IntoResponse { + Json(json!({ + "object": "list", + "data": [ + { + "id": "claude-sonnet-4-5", + "object": "model", + "created": 1706745600, + "owned_by": "anthropic" + }, + { + "id": "claude-opus-4-5-thinking", + "object": "model", + "created": 1706745600, + "owned_by": "anthropic" + }, + { + "id": "claude-3-5-sonnet-20241022", + "object": "model", + "created": 1706745600, + "owned_by": "anthropic" + } + ] + })) +} + +/// 计算 tokens (占位符) +pub async fn handle_count_tokens(Json(_body): Json) -> impl IntoResponse { + Json(json!({ + "input_tokens": 0, + "output_tokens": 0 + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_handle_list_models() { + let response = handle_list_models().await.into_response(); + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/server/src/proxy/handlers/gemini.rs b/server/src/proxy/handlers/gemini.rs new file mode 100644 index 0000000000000000000000000000000000000000..a0ed9d5cc043952efd4328d175168bf9cc0c0360 --- /dev/null +++ b/server/src/proxy/handlers/gemini.rs @@ -0,0 +1,259 @@ +// Gemini Handler +use axum::{extract::State, extract::{Json, Path}, http::StatusCode, response::IntoResponse}; +use serde_json::{json, Value}; +use tracing::{debug, error}; + +use crate::proxy::mappers::gemini::{wrap_request, unwrap_response}; +use crate::proxy::server::AppState; + +const MAX_RETRY_ATTEMPTS: usize = 3; + +/// 处理 generateContent 和 streamGenerateContent +/// 路径参数: model_name, method (e.g. "gemini-pro", "generateContent") +pub async fn handle_generate( + State(state): State, + Path(model_action): Path, + Json(body): Json +) -> Result { + // 解析 model:method + let (model_name, method) = if let Some((m, action)) = model_action.rsplit_once(':') { + (m.to_string(), action.to_string()) + } else { + (model_action, "generateContent".to_string()) + }; + + crate::modules::logger::log_info(&format!("Received Gemini request: {}/{}", model_name, method)); + + // 1. 验证方法 + if method != "generateContent" && method != "streamGenerateContent" { + return Err((StatusCode::BAD_REQUEST, format!("Unsupported method: {}", method))); + } + let is_stream = method == "streamGenerateContent"; + + // 2. 获取 UpstreamClient 和 TokenManager + let upstream = state.upstream.clone(); + let token_manager = state.token_manager; + let pool_size = token_manager.len(); + let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size).max(1); + + let mut last_error = String::new(); + + for attempt in 0..max_attempts { + // 3. 模型路由与配置解析 + let mapped_model = crate::proxy::common::model_mapping::resolve_model_route( + &model_name, + &*state.custom_mapping.read().await, + &*state.openai_mapping.read().await, + &*state.anthropic_mapping.read().await, + ); + // 提取 tools 列表以进行联网探测 (Gemini 风格可能是嵌套的) + let tools_val: Option> = body.get("tools").and_then(|t| t.as_array()).map(|arr| { + let mut flattened = Vec::new(); + for tool_entry in arr { + if let Some(decls) = tool_entry.get("functionDeclarations").and_then(|v| v.as_array()) { + flattened.extend(decls.iter().cloned()); + } else { + flattened.push(tool_entry.clone()); + } + } + flattened + }); + + let config = crate::proxy::mappers::common_utils::resolve_request_config(&model_name, &mapped_model, &tools_val); + + // 4. 获取 Token (使用准确的 request_type) + // 关键:在重试尝试 (attempt > 0) 时强制轮换账号 + let (access_token, project_id, email) = match token_manager.get_token(&config.request_type, attempt > 0).await { + Ok(t) => t, + Err(e) => { + return Err((StatusCode::SERVICE_UNAVAILABLE, format!("Token error: {}", e))); + } + }; + + tracing::info!("Using account: {} for request (type: {})", email, config.request_type); + + // 5. 包装请求 (project injection) + let wrapped_body = wrap_request(&body, &project_id, &mapped_model); + + // 5. 上游调用 + let query_string = if is_stream { Some("alt=sse") } else { None }; + let upstream_method = if is_stream { "streamGenerateContent" } else { "generateContent" }; + + let response = match upstream + .call_v1_internal(upstream_method, &access_token, wrapped_body, query_string) + .await { + Ok(r) => r, + Err(e) => { + last_error = e.clone(); + tracing::warn!("Gemini Request failed on attempt {}/{}: {}", attempt + 1, max_attempts, e); + continue; + } + }; + + let status = response.status(); + if status.is_success() { + // 6. 响应处理 + if is_stream { + use axum::body::Body; + use axum::response::Response; + use bytes::{Bytes, BytesMut}; + use futures::StreamExt; + + let mut response_stream = response.bytes_stream(); + let mut buffer = BytesMut::new(); + + let stream = async_stream::stream! { + while let Some(item) = response_stream.next().await { + match item { + Ok(bytes) => { + debug!("[Gemini-SSE] Received chunk: {} bytes", bytes.len()); + buffer.extend_from_slice(&bytes); + while let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_raw = buffer.split_to(pos + 1); + if let Ok(line_str) = std::str::from_utf8(&line_raw) { + let line = line_str.trim(); + if line.is_empty() { continue; } + + if line.starts_with("data: ") { + let json_part = line.trim_start_matches("data: ").trim(); + if json_part == "[DONE]" { + yield Ok::(Bytes::from("data: [DONE]\n\n")); + continue; + } + + match serde_json::from_str::(json_part) { + Ok(mut json) => { + // Unwrap v1internal response wrapper + if let Some(inner) = json.get_mut("response").map(|v| v.take()) { + let new_line = format!("data: {}\n\n", serde_json::to_string(&inner).unwrap_or_default()); + yield Ok::(Bytes::from(new_line)); + } else { + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&json).unwrap_or_default()))); + } + } + Err(e) => { + debug!("[Gemini-SSE] JSON parse error: {}, passing raw line", e); + yield Ok::(Bytes::from(format!("{}\n\n", line))); + } + } + } else { + // Non-data lines (comments, etc.) + yield Ok::(Bytes::from(format!("{}\n\n", line))); + } + } else { + // Non-UTF8 data? Just pass it through or skip + debug!("[Gemini-SSE] Non-UTF8 line encountered"); + yield Ok::(line_raw.freeze()); + } + } + } + Err(e) => { + error!("[Gemini-SSE] Connection error: {}", e); + yield Err(format!("Stream error: {}", e)); + } + } + } + }; + + let body = Body::from_stream(stream); + return Ok(Response::builder() + .header("Content-Type", "text/event-stream") + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive") + .body(body) + .unwrap() + .into_response()); + } + + let gemini_resp: Value = response + .json() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; + + let unwrapped = unwrap_response(&gemini_resp); + return Ok(Json(unwrapped).into_response()); + } + + // 处理错误并重试 + let status_code = status.as_u16(); + let error_text = response.text().await.unwrap_or_default(); + last_error = format!("HTTP {}: {}", status_code, error_text); + + // 只有 429 (限流), 403 (权限/地区限制) 和 401 (认证失效) 触发账号轮换 + if status_code == 429 || status_code == 403 || status_code == 401 { + // 只有明确包含 "QUOTA_EXHAUSTED" 才停止,避免误判上游的频率限制提示 (如 "check quota") + if status_code == 429 && error_text.contains("QUOTA_EXHAUSTED") { + error!("Gemini Quota exhausted (429) on attempt {}/{}, stopping to protect pool.", attempt + 1, max_attempts); + return Err((status, error_text)); + } + + tracing::warn!("Gemini Upstream {} on attempt {}/{}, rotating account", status_code, attempt + 1, max_attempts); + continue; + } + + // 404 等由于模型配置或路径错误的 HTTP 异常,直接报错,不进行无效轮换 + error!("Gemini Upstream non-retryable error {}: {}", status_code, error_text); + return Err((status, error_text)); + } + + Ok((StatusCode::TOO_MANY_REQUESTS, format!("All accounts exhausted. Last error: {}", last_error)).into_response()) +} + +pub async fn handle_list_models(State(state): State) -> Result { + let model_group = "gemini"; + let (access_token, _, _) = state.token_manager.get_token(model_group, false).await + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Token error: {}", e)))?; + + // Fetch from upstream + let upstream_models = state.upstream.fetch_available_models(&access_token).await + .map_err(|e| (StatusCode::BAD_GATEWAY, e))?; + + // Transform map to Gemini list format + let mut models = Vec::new(); + if let Some(obj) = upstream_models.as_object() { + tracing::info!("Upstream models keys: {:?}", obj.keys()); + for (key, value) in obj { + let description = value.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let display_name = value.get("displayName").and_then(|v| v.as_str()).unwrap_or(key); + + models.push(json!({ + "name": format!("models/{}", key), + "version": "001", + "displayName": display_name, + "description": description, + "inputTokenLimit": 128000, + "outputTokenLimit": 8192, + "supportedGenerationMethods": ["generateContent", "countTokens"], + "temperature": 1.0, + "topP": 0.95, + "topK": 64 + })); + } + } + + // Fallback + if models.is_empty() { + models.push(json!({ + "name": "models/gemini-2.5-pro", + "displayName": "Gemini 2.5 Pro", + "supportedGenerationMethods": ["generateContent", "countTokens"] + })); + } + + Ok(Json(json!({ "models": models }))) +} + +pub async fn handle_get_model(Path(model_name): Path) -> impl IntoResponse { + Json(json!({ + "name": format!("models/{}", model_name), + "displayName": model_name + })) +} + +pub async fn handle_count_tokens(State(state): State, Path(_model_name): Path, Json(_body): Json) -> Result { + let model_group = "gemini"; + let (_access_token, _project_id, _) = state.token_manager.get_token(model_group, false).await + .map_err(|e| (StatusCode::SERVICE_UNAVAILABLE, format!("Token error: {}", e)))?; + + Ok(Json(json!({"totalTokens": 0}))) +} diff --git a/server/src/proxy/handlers/mod.rs b/server/src/proxy/handlers/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..4a7c9bb8376d3168e00608e36e388fcdb8d56d43 --- /dev/null +++ b/server/src/proxy/handlers/mod.rs @@ -0,0 +1,6 @@ +// Handlers 模块 - API 端点处理器 +// 核心端点处理器模块 + +pub mod claude; +pub mod openai; +pub mod gemini; diff --git a/server/src/proxy/handlers/openai.rs b/server/src/proxy/handlers/openai.rs new file mode 100644 index 0000000000000000000000000000000000000000..4b6c5669072375922da590dcf355e2a7f5c13923 --- /dev/null +++ b/server/src/proxy/handlers/openai.rs @@ -0,0 +1,520 @@ +// OpenAI Handler +use axum::{extract::State, extract::Json, http::StatusCode, response::IntoResponse}; +use serde_json::{json, Value}; +use tracing::{debug, error}; + +use crate::proxy::mappers::openai::{transform_openai_request, transform_openai_response, OpenAIRequest}; +// use crate::proxy::upstream::client::UpstreamClient; // 通过 state 获取 +use crate::proxy::server::AppState; + +const MAX_RETRY_ATTEMPTS: usize = 3; + +pub async fn handle_chat_completions( + State(state): State, + Json(body): Json +) -> Result { + let mut openai_req: OpenAIRequest = serde_json::from_value(body) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid request: {}", e)))?; + + // Safety: Ensure messages is not empty + if openai_req.messages.is_empty() { + tracing::warn!("Received request with empty messages, injecting fallback..."); + openai_req.messages.push(crate::proxy::mappers::openai::OpenAIMessage { + role: "user".to_string(), + content: Some(crate::proxy::mappers::openai::OpenAIContent::String(" ".to_string())), + tool_calls: None, + tool_call_id: None, + name: None, + }); + } + + debug!("Received OpenAI request for model: {}", openai_req.model); + + // 1. 获取 UpstreamClient (Clone handle) + let upstream = state.upstream.clone(); + let token_manager = state.token_manager; + let pool_size = token_manager.len(); + let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size).max(1); + + let mut last_error = String::new(); + + for attempt in 0..max_attempts { + // 2. 预解析模型路由与配置 + let mapped_model = crate::proxy::common::model_mapping::resolve_model_route( + &openai_req.model, + &*state.custom_mapping.read().await, + &*state.openai_mapping.read().await, + &*state.anthropic_mapping.read().await, + ); + // 将 OpenAI 工具转为 Value 数组以便探测联网 + let tools_val: Option> = openai_req.tools.as_ref().map(|list| { + list.iter().cloned().collect() + }); + let config = crate::proxy::mappers::common_utils::resolve_request_config(&openai_req.model, &mapped_model, &tools_val); + + // 3. 获取 Token (使用准确的 request_type) + // 关键:在重试尝试 (attempt > 0) 时强制轮换账号 + let (access_token, project_id, email) = match token_manager.get_token(&config.request_type, attempt > 0).await { + Ok(t) => t, + Err(e) => { + return Err((StatusCode::SERVICE_UNAVAILABLE, format!("Token error: {}", e))); + } + }; + + tracing::info!("Using account: {} for request (type: {})", email, config.request_type); + + // 4. 转换请求 + let gemini_body = transform_openai_request(&openai_req, &project_id, &mapped_model); + + // [New] 打印转换后的报文 (Gemini Body) 供调试 + if let Ok(body_json) = serde_json::to_string_pretty(&gemini_body) { + tracing::info!("[OpenAI-Request] Transformed Gemini Body:\n{}", body_json); + } + + // 5. 发送请求 + let list_response = openai_req.stream; + let method = if list_response { "streamGenerateContent" } else { "generateContent" }; + let query_string = if list_response { Some("alt=sse") } else { None }; + + let response = match upstream + .call_v1_internal(method, &access_token, gemini_body, query_string) + .await { + Ok(r) => r, + Err(e) => { + last_error = e.clone(); + tracing::warn!("OpenAI Request failed on attempt {}/{}: {}", attempt + 1, max_attempts, e); + continue; + } + }; + + let status = response.status(); + if status.is_success() { + // 5. 处理流式 vs 非流式 + if list_response { + use crate::proxy::mappers::openai::streaming::create_openai_sse_stream; + use axum::response::Response; + use axum::body::Body; + // Removed redundant StreamExt + + let gemini_stream = response.bytes_stream(); + let openai_stream = create_openai_sse_stream(Box::pin(gemini_stream), openai_req.model.clone()); + let body = Body::from_stream(openai_stream); + + return Ok(Response::builder() + .header("Content-Type", "text/event-stream") + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive") + .body(body) + .unwrap() + .into_response()); + } + + let gemini_resp: Value = response + .json() + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; + + let openai_response = transform_openai_response(&gemini_resp); + return Ok(Json(openai_response).into_response()); + } + + // 处理特定错误并重试 + let status_code = status.as_u16(); + let error_text = response.text().await.unwrap_or_default(); + last_error = format!("HTTP {}: {}", status_code, error_text); + + // [New] 打印错误报文日志 + tracing::error!("[OpenAI-Upstream] Error Response {}: {}", status_code, error_text); + + // 429 智能处理 + if status_code == 429 { + // 1. 优先尝试解析 RetryInfo (由 Google Cloud 直接下发) + if let Some(delay_ms) = crate::proxy::upstream::retry::parse_retry_delay(&error_text) { + let actual_delay = delay_ms.saturating_add(200).min(10_000); + tracing::warn!( + "OpenAI Upstream 429 on attempt {}/{}, waiting {}ms then retrying", + attempt + 1, + max_attempts, + actual_delay + ); + tokio::time::sleep(tokio::time::Duration::from_millis(actual_delay)).await; + continue; + } + + // 2. 只有明确包含 "QUOTA_EXHAUSTED" 才停止,避免误判频率提示 (如 "check quota") + if error_text.contains("QUOTA_EXHAUSTED") { + error!("OpenAI Quota exhausted (429) on attempt {}/{}, stopping to protect pool.", attempt + 1, max_attempts); + return Err((status, error_text)); + } + + // 3. 其他 429 情况(如无重试指示的频率限制),轮换账号 + tracing::warn!("OpenAI Upstream 429 on attempt {}/{}, rotating account", attempt + 1, max_attempts); + continue; + } + + // 只有 403 (权限/地区限制) 和 401 (认证失效) 触发账号轮换 + if status_code == 403 || status_code == 401 { + tracing::warn!("OpenAI Upstream {} on attempt {}/{}, rotating account", status_code, attempt + 1, max_attempts); + continue; + } + + // 404 等由于模型配置或路径错误的 HTTP 异常,直接报错,不进行无效轮换 + error!("OpenAI Upstream non-retryable error {}: {}", status_code, error_text); + return Err((status, error_text)); + } + + // 所有尝试均失败 + Err((StatusCode::TOO_MANY_REQUESTS, format!("All accounts exhausted. Last error: {}", last_error))) +} + +/// 处理 Legacy Completions API (/v1/completions) +/// 将 Prompt 转换为 Chat Message 格式,复用 handle_chat_completions +pub async fn handle_completions( + State(state): State, + Json(mut body): Json, +) -> Result { + tracing::info!("Received /v1/completions or /v1/responses payload: {:?}", body); + + let is_codex_style = body.get("input").is_some() && body.get("instructions").is_some(); + + // 1. Convert Payload to Messages (Shared Chat Format) + if is_codex_style { + let instructions = body.get("instructions").and_then(|v| v.as_str()).unwrap_or_default(); + let input_items = body.get("input").and_then(|v| v.as_array()); + + let mut messages = Vec::new(); + + // System Instructions + if !instructions.is_empty() { + messages.push(json!({ "role": "system", "content": instructions })); + } + + let mut call_id_to_name = std::collections::HashMap::new(); + + // Pass 1: Build Call ID to Name Map + if let Some(items) = input_items { + for item in items { + let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match item_type { + "function_call" | "local_shell_call" | "web_search_call" => { + let call_id = item.get("call_id").and_then(|v| v.as_str()) + .or_else(|| item.get("id").and_then(|v| v.as_str())) + .unwrap_or("unknown"); + + let name = if item_type == "local_shell_call" { + "shell" + } else if item_type == "web_search_call" { + "google_search" + } else { + item.get("name").and_then(|v| v.as_str()).unwrap_or("unknown") + }; + + call_id_to_name.insert(call_id.to_string(), name.to_string()); + tracing::debug!("Mapped call_id {} to name {}", call_id, name); + } + _ => {} + } + } + } + + // Pass 2: Map Input Items to Messages + if let Some(items) = input_items { + for item in items { + let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match item_type { + "message" => { + let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user"); + let content = item.get("content").and_then(|v| v.as_array()); + let mut text_parts = Vec::new(); + let mut image_parts: Vec = Vec::new(); + + if let Some(parts) = content { + for part in parts { + // 处理文本块 + if let Some(text) = part.get("text").and_then(|v| v.as_str()) { + text_parts.push(text.to_string()); + } + // [NEW] 处理图像块 (Codex input_image 格式) + else if part.get("type").and_then(|v| v.as_str()) == Some("input_image") { + if let Some(image_url) = part.get("image_url").and_then(|v| v.as_str()) { + image_parts.push(json!({ + "type": "image_url", + "image_url": { "url": image_url } + })); + tracing::info!("[Codex] Found input_image: {}", image_url); + } + } + // [NEW] 兼容标准 OpenAI image_url 格式 + else if part.get("type").and_then(|v| v.as_str()) == Some("image_url") { + if let Some(url_obj) = part.get("image_url") { + image_parts.push(json!({ + "type": "image_url", + "image_url": url_obj.clone() + })); + } + } + } + } + + // 构造消息内容:如果有图像则使用数组格式 + if image_parts.is_empty() { + messages.push(json!({ + "role": role, + "content": text_parts.join("\n") + })); + } else { + let mut content_blocks: Vec = Vec::new(); + if !text_parts.is_empty() { + content_blocks.push(json!({ + "type": "text", + "text": text_parts.join("\n") + })); + } + content_blocks.extend(image_parts); + messages.push(json!({ + "role": role, + "content": content_blocks + })); + } + } + "function_call" | "local_shell_call" | "web_search_call" => { + let mut name = item.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); + let mut args_str = item.get("arguments").and_then(|v| v.as_str()).unwrap_or("{}").to_string(); + let call_id = item.get("call_id").and_then(|v| v.as_str()).or_else(|| item.get("id").and_then(|v| v.as_str())).unwrap_or("unknown"); + + // Handle native shell calls + if item_type == "local_shell_call" { + name = "shell"; + if let Some(action) = item.get("action") { + if let Some(exec) = action.get("exec") { + // Map to ShellCommandToolCallParams (string command) or ShellToolCallParams (array command) + // Most LLMs prefer a single string for shell + let mut args_obj = serde_json::Map::new(); + if let Some(cmd) = exec.get("command") { + // CRITICAL FIX: The 'shell' tool schema defines 'command' as an ARRAY of strings. + // We MUST pass it as an array, not a joined string, otherwise Gemini rejects with 400 INVALID_ARGUMENT. + let cmd_val = if cmd.is_string() { + json!([cmd]) // Wrap in array + } else { + cmd.clone() // Assume already array + }; + args_obj.insert("command".to_string(), cmd_val); + } + if let Some(wd) = exec.get("working_directory").or(exec.get("workdir")) { + args_obj.insert("workdir".to_string(), wd.clone()); + } + args_str = serde_json::to_string(&args_obj).unwrap_or("{}".to_string()); + } + } + } else if item_type == "web_search_call" { + name = "google_search"; + if let Some(action) = item.get("action") { + let mut args_obj = serde_json::Map::new(); + if let Some(q) = action.get("query") { + args_obj.insert("query".to_string(), q.clone()); + } + args_str = serde_json::to_string(&args_obj).unwrap_or("{}".to_string()); + } + } + + messages.push(json!({ + "role": "assistant", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": name, + "arguments": args_str + } + } + ] + })); + } + "function_call_output" | "custom_tool_call_output" => { + let call_id = item.get("call_id").and_then(|v| v.as_str()).unwrap_or("unknown"); + let output = item.get("output"); + let output_str = if let Some(o) = output { + if o.is_string() { o.as_str().unwrap().to_string() } + else if let Some(content) = o.get("content").and_then(|v| v.as_str()) { content.to_string() } + else { o.to_string() } + } else { "".to_string() }; + + let name = call_id_to_name.get(call_id).cloned().unwrap_or_else(|| { + // Fallback: if unknown and we see function_call_output, it's likely "shell" in this context + tracing::warn!("Unknown tool name for call_id {}, defaulting to 'shell'", call_id); + "shell".to_string() + }); + + messages.push(json!({ + "role": "tool", + "tool_call_id": call_id, + "name": name, + "content": output_str + })); + } + _ => {} + } + } + } + + if let Some(obj) = body.as_object_mut() { + obj.insert("messages".to_string(), json!(messages)); + } + } else if let Some(prompt_val) = body.get("prompt") { + // Legacy OpenAI Style: prompt -> Chat + let prompt_str = match prompt_val { + Value::String(s) => s.clone(), + Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::>().join("\n"), + _ => prompt_val.to_string(), + }; + let messages = json!([ { "role": "user", "content": prompt_str } ]); + if let Some(obj) = body.as_object_mut() { + obj.remove("prompt"); + obj.insert("messages".to_string(), messages); + } + } + + // 2. Reuse handle_chat_completions logic (wrapping with custom handler or direct call) + // Actually, due to SSE handling differences (Codex uses different event format), we replicate the loop here or abstract it. + // For now, let's replicate the core loop but with Codex specific SSE mapping. + + let mut openai_req: OpenAIRequest = serde_json::from_value(body.clone()) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid request: {}", e)))?; + + // Safety: Inject empty message if needed + if openai_req.messages.is_empty() { + openai_req.messages.push(crate::proxy::mappers::openai::OpenAIMessage { + role: "user".to_string(), + content: Some(crate::proxy::mappers::openai::OpenAIContent::String(" ".to_string())), + tool_calls: None, + tool_call_id: None, + name: None, + }); + } + + let upstream = state.upstream.clone(); + let token_manager = state.token_manager; + let pool_size = token_manager.len(); + let max_attempts = MAX_RETRY_ATTEMPTS.min(pool_size).max(1); + + let mut last_error = String::new(); + + for attempt in 0..max_attempts { + let mapped_model = crate::proxy::common::model_mapping::resolve_model_route( + &openai_req.model, + &*state.custom_mapping.read().await, + &*state.openai_mapping.read().await, + &*state.anthropic_mapping.read().await, + ); + // 将 OpenAI 工具转为 Value 数组以便探测联网 + let tools_val: Option> = openai_req.tools.as_ref().map(|list| { + list.iter().cloned().collect() + }); + let config = crate::proxy::mappers::common_utils::resolve_request_config(&openai_req.model, &mapped_model, &tools_val); + + let (access_token, project_id, email) = match token_manager.get_token(&config.request_type, false).await { + Ok(t) => t, + Err(e) => return Err((StatusCode::SERVICE_UNAVAILABLE, format!("Token error: {}", e))), + }; + + tracing::info!("Using account: {} for completions request (type: {})", email, config.request_type); + + let gemini_body = transform_openai_request(&openai_req, &project_id, &mapped_model); + + // [New] 打印转换后的报文 (Gemini Body) 供调试 (Codex 路径) + if let Ok(body_json) = serde_json::to_string_pretty(&gemini_body) { + tracing::info!("[Codex-Request] Transformed Gemini Body:\n{}", body_json); + } + + let list_response = openai_req.stream; + let method = if list_response { "streamGenerateContent" } else { "generateContent" }; + let query_string = if list_response { Some("alt=sse") } else { None }; + + let response = match upstream.call_v1_internal(method, &access_token, gemini_body, query_string).await { + Ok(r) => r, + Err(e) => { + last_error = e.clone(); + continue; + } + }; + + let status = response.status(); + if status.is_success() { + if list_response { + use axum::response::Response; + use axum::body::Body; + + let gemini_stream = response.bytes_stream(); + let body = if is_codex_style { + use crate::proxy::mappers::openai::streaming::create_codex_sse_stream; + let s = create_codex_sse_stream(Box::pin(gemini_stream), openai_req.model.clone()); + Body::from_stream(s) + } else { + use crate::proxy::mappers::openai::streaming::create_legacy_sse_stream; + let s = create_legacy_sse_stream(Box::pin(gemini_stream), openai_req.model.clone()); + Body::from_stream(s) + }; + + return Ok(Response::builder() + .header("Content-Type", "text/event-stream") + .header("Cache-Control", "no-cache") + .header("Connection", "keep-alive") + .body(body) + .unwrap() + .into_response()); + } + + let gemini_resp: Value = response.json().await + .map_err(|e| (StatusCode::BAD_GATEWAY, format!("Parse error: {}", e)))?; + + let chat_resp = transform_openai_response(&gemini_resp); + + // Map Chat Response -> Legacy Completions Response + let choices = chat_resp.choices.iter().map(|c| { + json!({ + "text": match &c.message.content { + Some(crate::proxy::mappers::openai::OpenAIContent::String(s)) => s.clone(), + _ => "".to_string() + }, + "index": c.index, + "logprobs": null, + "finish_reason": c.finish_reason + }) + }).collect::>(); + + let legacy_resp = json!({ + "id": chat_resp.id, + "object": "text_completion", + "created": chat_resp.created, + "model": chat_resp.model, + "choices": choices + }); + + return Ok(axum::Json(legacy_resp).into_response()); + } + + // Handle errors and retry + let status_code = status.as_u16(); + let error_text = response.text().await.unwrap_or_default(); + last_error = format!("HTTP {}: {}", status_code, error_text); + + if status_code == 429 || status_code == 403 || status_code == 401 { + continue; + } + return Err((status, error_text)); + } + + Err((StatusCode::TOO_MANY_REQUESTS, format!("All attempts failed. Last error: {}", last_error))) +} + +pub async fn handle_list_models() -> impl IntoResponse { + Json(json!({ + "object": "list", + "data": [ + {"id": "gpt-4", "object": "model", "created": 1706745600, "owned_by": "openai"}, + {"id": "gpt-3.5-turbo", "object": "model", "created": 1706745600, "owned_by": "openai"}, + {"id": "o1-mini", "object": "model", "created": 1706745600, "owned_by": "openai"} + ] + })) +} diff --git a/server/src/proxy/mappers/claude/mod.rs b/server/src/proxy/mappers/claude/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..12289498816028b0571535e9d51a78ee772fc66b --- /dev/null +++ b/server/src/proxy/mappers/claude/mod.rs @@ -0,0 +1,356 @@ +// Claude mapper 模块 +// 负责 Claude ↔ Gemini 协议转换 + +pub mod models; +pub mod request; +pub mod response; +pub mod streaming; +pub mod utils; + +pub use models::*; +pub use request::transform_claude_request_in; +pub use response::transform_response; +pub use streaming::{PartProcessor, StreamingState}; + +use bytes::Bytes; +use futures::Stream; +use std::pin::Pin; + +/// 创建从 Gemini SSE 流到 Claude SSE 流的转换 +pub fn create_claude_sse_stream( + mut gemini_stream: Pin> + Send>>, + trace_id: String, +) -> Pin> + Send>> { + use async_stream::stream; + use bytes::BytesMut; + use futures::StreamExt; + + Box::pin(stream! { + let mut state = StreamingState::new(); + let mut buffer = BytesMut::new(); + + while let Some(chunk_result) = gemini_stream.next().await { + match chunk_result { + Ok(chunk) => { + buffer.extend_from_slice(&chunk); + + // Process complete lines + while let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_raw = buffer.split_to(pos + 1); + if let Ok(line_str) = std::str::from_utf8(&line_raw) { + let line = line_str.trim(); + if line.is_empty() { continue; } + + if let Some(sse_chunks) = process_sse_line(line, &mut state, &trace_id) { + for sse_chunk in sse_chunks { + yield Ok(sse_chunk); + } + } + } + } + } + Err(e) => { + yield Err(format!("Stream error: {}", e)); + break; + } + } + } + + // Ensure termination events are sent + for chunk in emit_force_stop(&mut state) { + yield Ok(chunk); + } + }) +} + +/// 处理单行 SSE 数据 +fn process_sse_line(line: &str, state: &mut StreamingState, trace_id: &str) -> Option> { + if !line.starts_with("data: ") { + return None; + } + + let data_str = line[6..].trim(); + if data_str.is_empty() { + return None; + } + + if data_str == "[DONE]" { + let chunks = emit_force_stop(state); + if chunks.is_empty() { + return None; + } + return Some(chunks); + } + + // 解析 JSON + let json_value: serde_json::Value = match serde_json::from_str(data_str) { + Ok(v) => v, + Err(_) => return None, + }; + + let mut chunks = Vec::new(); + + // 解包 response 字段 (如果存在) + let raw_json = json_value.get("response").unwrap_or(&json_value); + + // 发送 message_start + if !state.message_start_sent { + chunks.push(state.emit_message_start(raw_json)); + } + + // 捕获 groundingMetadata (Web Search) + if let Some(candidate) = raw_json.get("candidates").and_then(|c| c.get(0)) { + if let Some(grounding) = candidate.get("groundingMetadata") { + // 提取搜索词 + if let Some(query) = grounding.get("webSearchQueries") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.get(0)) + .and_then(|v| v.as_str()) + { + state.web_search_query = Some(query.to_string()); + } + + // 提取结果块 + if let Some(chunks_arr) = grounding.get("groundingChunks").and_then(|v| v.as_array()) { + state.grounding_chunks = Some(chunks_arr.clone()); + } else if let Some(chunks_arr) = grounding.get("grounding_metadata").and_then(|m| m.get("groundingChunks")).and_then(|v| v.as_array()) { + state.grounding_chunks = Some(chunks_arr.clone()); + } + } + } + + // 处理所有 parts + if let Some(parts) = raw_json + .get("candidates") + .and_then(|c| c.get(0)) + .and_then(|cand| cand.get("content")) + .and_then(|content| content.get("parts")) + .and_then(|p| p.as_array()) + { + for part_value in parts { + if let Ok(part) = serde_json::from_value::(part_value.clone()) { + let mut processor = PartProcessor::new(state); + chunks.extend(processor.process(&part)); + } + } + } + + // Process grounding metadata (googleSearch results) and append as citations + if let Some(grounding) = raw_json + .get("candidates") + .and_then(|c| c.get(0)) + .and_then(|cand| cand.get("groundingMetadata")) + { + if let Some(citation_chunks) = process_grounding_metadata(grounding, state) { + chunks.extend(citation_chunks); + } + } + + // 检查是否结束 + if let Some(finish_reason) = raw_json + .get("candidates") + .and_then(|c| c.get(0)) + .and_then(|cand| cand.get("finishReason")) + .and_then(|f| f.as_str()) + { + let usage = raw_json + .get("usageMetadata") + .and_then(|u| serde_json::from_value::(u.clone()).ok()); + + if let Some(ref u) = usage { + tracing::info!( + "[{}] Stream usage: In {}, Out {}", + trace_id, + u.prompt_token_count.unwrap_or(0), + u.candidates_token_count.unwrap_or(0) + ); + } + + chunks.extend(state.emit_finish(Some(finish_reason), usage.as_ref())); + } + + if chunks.is_empty() { + None + } else { + Some(chunks) + } +} + +/// 发送强制结束事件 +pub fn emit_force_stop(state: &mut StreamingState) -> Vec { + if !state.message_stop_sent { + let mut chunks = state.emit_finish(None, None); + if chunks.is_empty() { + chunks.push(Bytes::from( + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + )); + state.message_stop_sent = true; + } + return chunks; + } + vec![] +} + +/// Process grounding metadata from Gemini's googleSearch and emit as Claude web_search blocks +fn process_grounding_metadata( + metadata: &serde_json::Value, + state: &mut StreamingState, +) -> Option> { + use serde_json::json; + + // Extract search queries and grounding chunks + let search_queries = metadata + .get("webSearchQueries") + .and_then(|q| q.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>()) + .unwrap_or_default(); + + let grounding_chunks = metadata.get("groundingChunks").and_then(|c| c.as_array())?; + + if grounding_chunks.is_empty() { + return None; + } + + // Generate a unique tool_use_id + let tool_use_id = format!( + "srvtoolu_{}", + crate::proxy::common::utils::generate_random_id() + ); + + // Build search results array + let mut search_results = Vec::new(); + for chunk in grounding_chunks.iter() { + if let Some(web) = chunk.get("web") { + let title = web + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or("Source"); + let uri = web.get("uri").and_then(|u| u.as_str()).unwrap_or(""); + if !uri.is_empty() { + search_results.push(json!({ + "url": uri, + "title": title, + "encrypted_content": "", // Gemini doesn't provide this + "page_age": null + })); + } + } + } + + if search_results.is_empty() { + return None; + } + + let search_query = search_queries + .first() + .map(|s| s.to_string()) + .unwrap_or_default(); + + tracing::info!( + "[Grounding] Emitting {} search results for query: {}", + search_results.len(), + search_query + ); + + let mut chunks = Vec::new(); + + // 1. Emit server_tool_use block (start) + let server_tool_use_start = json!({ + "type": "content_block_start", + "index": state.block_index, + "content_block": { + "type": "server_tool_use", + "id": tool_use_id, + "name": "web_search", + "input": { + "query": search_query + } + } + }); + chunks.push(Bytes::from(format!( + "event: content_block_start\ndata: {}\n\n", + server_tool_use_start + ))); + + // server_tool_use block stop + let server_tool_use_stop = json!({ + "type": "content_block_stop", + "index": state.block_index + }); + chunks.push(Bytes::from(format!( + "event: content_block_stop\ndata: {}\n\n", + server_tool_use_stop + ))); + state.block_index += 1; + + // 2. Emit web_search_tool_result block (start) + let tool_result_start = json!({ + "type": "content_block_start", + "index": state.block_index, + "content_block": { + "type": "web_search_tool_result", + "tool_use_id": tool_use_id, + "content": search_results + } + }); + chunks.push(Bytes::from(format!( + "event: content_block_start\ndata: {}\n\n", + tool_result_start + ))); + + // web_search_tool_result block stop + let tool_result_stop = json!({ + "type": "content_block_stop", + "index": state.block_index + }); + chunks.push(Bytes::from(format!( + "event: content_block_stop\ndata: {}\n\n", + tool_result_stop + ))); + state.block_index += 1; + + Some(chunks) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_process_sse_line_done() { + let mut state = StreamingState::new(); + let result = process_sse_line("data: [DONE]", &mut state, "test_id"); + assert!(result.is_some()); + let chunks = result.unwrap(); + assert!(!chunks.is_empty()); + + let all_text: String = chunks + .iter() + .map(|b| String::from_utf8(b.to_vec()).unwrap_or_default()) + .collect(); + assert!(all_text.contains("message_stop")); + } + + #[test] + fn test_process_sse_line_with_text() { + let mut state = StreamingState::new(); + + let test_data = r#"data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}],"usageMetadata":{},"modelVersion":"test","responseId":"123"}"#; + + let result = process_sse_line(test_data, &mut state, "test_id"); + assert!(result.is_some()); + + let chunks = result.unwrap(); + assert!(!chunks.is_empty()); + + // 应该包含 message_start 和 text delta + let all_text: String = chunks + .iter() + .map(|b| String::from_utf8(b.to_vec()).unwrap_or_default()) + .collect(); + + assert!(all_text.contains("message_start")); + assert!(all_text.contains("content_block_start")); + assert!(all_text.contains("Hello")); + } +} diff --git a/server/src/proxy/mappers/claude/models.rs b/server/src/proxy/mappers/claude/models.rs new file mode 100644 index 0000000000000000000000000000000000000000..58198cd32752f7576486c99ac374d5e027351542 --- /dev/null +++ b/server/src/proxy/mappers/claude/models.rs @@ -0,0 +1,379 @@ +// Claude 数据模型 +// Claude 协议相关数据模型 + +use serde::{Deserialize, Serialize}; + +/// Claude API 请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(default)] + pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +/// Thinking 配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThinkingConfig { + #[serde(rename = "type")] + pub type_: String, // "enabled" + #[serde(skip_serializing_if = "Option::is_none")] + pub budget_tokens: Option, +} + +/// System Prompt +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SystemPrompt { + String(String), + Array(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemBlock { + #[serde(rename = "type")] + pub block_type: String, + pub text: String, +} + +/// Message +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub role: String, + pub content: MessageContent, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MessageContent { + String(String), + Array(Vec), +} + +/// Content Block (Claude) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ContentBlock { + #[serde(rename = "text")] + Text { text: String }, + + #[serde(rename = "thinking")] + Thinking { + thinking: String, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + + #[serde(rename = "image")] + Image { source: ImageSource }, + + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + + #[serde(rename = "tool_result")] + ToolResult { + tool_use_id: String, + content: serde_json::Value, // Changed from String to Value to support Array of Blocks + #[serde(skip_serializing_if = "Option::is_none")] + is_error: Option, + }, + + #[serde(rename = "server_tool_use")] + ServerToolUse { + id: String, + name: String, + input: serde_json::Value, + }, + + #[serde(rename = "web_search_tool_result")] + WebSearchToolResult { + tool_use_id: String, + content: serde_json::Value, + }, + + #[serde(rename = "redacted_thinking")] + RedactedThinking { data: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageSource { + #[serde(rename = "type")] + pub source_type: String, + pub media_type: String, + pub data: String, +} + +/// Tool - supports both client tools (with input_schema) and server tools (like web_search) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tool { + /// Tool type - for server tools like "web_search_20250305" + #[serde(rename = "type")] + #[serde(skip_serializing_if = "Option::is_none")] + pub type_: Option, + /// Tool name - "web_search" for server tools, custom name for client tools + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Input schema - required for client tools, absent for server tools + #[serde(skip_serializing_if = "Option::is_none")] + pub input_schema: Option, +} + +impl Tool { + /// Check if this is the web_search server tool + pub fn is_web_search(&self) -> bool { + // Check by type (preferred for server tools) + if let Some(ref t) = self.type_ { + if t.starts_with("web_search") { + return true; + } + } + // Check by name (fallback) + if let Some(ref n) = self.name { + if n == "web_search" { + return true; + } + } + false + } + + /// Get the effective tool name + pub fn get_name(&self) -> String { + self.name.clone().unwrap_or_else(|| { + // For server tools, derive name from type + if let Some(ref t) = self.type_ { + if t.starts_with("web_search") { + return "web_search".to_string(); + } + } + "unknown".to_string() + }) + } +} + +/// Metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Metadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, +} + +/// Claude API 响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeResponse { + pub id: String, + #[serde(rename = "type")] + pub type_: String, + pub role: String, + pub model: String, + pub content: Vec, + pub stop_reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_sequence: Option, + pub usage: Usage, +} + +/// Usage +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Usage { + pub input_tokens: u32, + pub output_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_tool_use: Option, +} + +// ========== Gemini 数据模型 ========== + +/// Gemini Content +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiContent { + pub role: String, + pub parts: Vec, +} + +/// Gemini Part +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiPart { + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub thought: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "thoughtSignature")] + pub thought_signature: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "functionCall")] + pub function_call: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "functionResponse")] + pub function_response: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "inlineData")] + pub inline_data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionCall { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionResponse { + pub name: String, + pub response: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlineData { + #[serde(rename = "mimeType")] + pub mime_type: String, + pub data: String, +} + +/// Gemini 完整响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeminiResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub candidates: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "usageMetadata")] + pub usage_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "modelVersion")] + pub model_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "responseId")] + pub response_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Candidate { + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "finishReason")] + pub finish_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "groundingMetadata")] + pub grounding_metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "promptTokenCount")] + pub prompt_token_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "candidatesTokenCount")] + pub candidates_token_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "totalTokenCount")] + pub total_token_count: Option, +} + +// ========== Grounding Metadata (for googleSearch results) ========== + +/// Gemini Grounding Metadata - contains search results from googleSearch tool +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroundingMetadata { + #[serde(rename = "webSearchQueries")] + #[serde(skip_serializing_if = "Option::is_none")] + pub web_search_queries: Option>, + + #[serde(rename = "groundingChunks")] + #[serde(skip_serializing_if = "Option::is_none")] + pub grounding_chunks: Option>, + + #[serde(rename = "groundingSupports")] + #[serde(skip_serializing_if = "Option::is_none")] + pub grounding_supports: Option>, + + #[serde(rename = "searchEntryPoint")] + #[serde(skip_serializing_if = "Option::is_none")] + pub search_entry_point: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroundingChunk { + #[serde(skip_serializing_if = "Option::is_none")] + pub web: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebSource { + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroundingSupport { + #[serde(skip_serializing_if = "Option::is_none")] + pub segment: Option, + #[serde(rename = "groundingChunkIndices")] + #[serde(skip_serializing_if = "Option::is_none")] + pub grounding_chunk_indices: Option>, + #[serde(rename = "confidenceScores")] + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence_scores: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TextSegment { + #[serde(rename = "startIndex")] + #[serde(skip_serializing_if = "Option::is_none")] + pub start_index: Option, + #[serde(rename = "endIndex")] + #[serde(skip_serializing_if = "Option::is_none")] + pub end_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchEntryPoint { + #[serde(rename = "renderedContent")] + #[serde(skip_serializing_if = "Option::is_none")] + pub rendered_content: Option, +} diff --git a/server/src/proxy/mappers/claude/request.rs b/server/src/proxy/mappers/claude/request.rs new file mode 100644 index 0000000000000000000000000000000000000000..946baa92a88c1f48fec03a62a1c99bc45e2f7bc6 --- /dev/null +++ b/server/src/proxy/mappers/claude/request.rs @@ -0,0 +1,688 @@ +// Claude 请求转换 (Claude → Gemini v1internal) +// 对应 transformClaudeRequestIn + +use super::models::*; +use crate::proxy::mappers::signature_store::get_thought_signature; +use serde_json::{json, Value}; +use std::collections::HashMap; + +/// 转换 Claude 请求为 Gemini v1internal 格式 +pub fn transform_claude_request_in( + claude_req: &ClaudeRequest, + project_id: &str, +) -> Result { + // 检测是否有联网工具 (server tool or built-in tool) + let has_web_search_tool = claude_req + .tools + .as_ref() + .map(|tools| { + tools.iter().any(|t| { + t.is_web_search() + || t.name.as_deref() == Some("google_search") + || t.type_.as_deref() == Some("web_search_20250305") + }) + }) + .unwrap_or(false); + + // 用于存储 tool_use id -> name 映射 + let mut tool_id_to_name: HashMap = HashMap::new(); + + // 1. System Instruction (注入动态身份防护) + let system_instruction = build_system_instruction(&claude_req.system, &claude_req.model); + + // Map model name (Use standard mapping) + let mapped_model = if has_web_search_tool { + "gemini-2.5-flash".to_string() + } else { + crate::proxy::common::model_mapping::map_claude_model_to_gemini(&claude_req.model) + }; + + // 将 Claude 工具转为 Value 数组以便探测联网 + let tools_val: Option> = claude_req.tools.as_ref().map(|list| { + list.iter().map(|t| serde_json::to_value(t).unwrap_or(json!({}))).collect() + }); + + // Resolve grounding config + let config = crate::proxy::mappers::common_utils::resolve_request_config(&claude_req.model, &mapped_model, &tools_val); + // Only Gemini models support our "dummy thought" workaround. + // Claude models routed via Vertex/Google API often require valid thought signatures. + // [FIX] Whenever thinking is enabled, we MUST allow dummy thought injection to satisfy + // Google's strict validation of historical messages, even for non-agent (e.g. search) tasks. + let is_thinking_enabled = claude_req + .thinking + .as_ref() + .map(|t| t.type_ == "enabled") + .unwrap_or(false); + + let allow_dummy_thought = is_thinking_enabled; + + // 4. Generation Config & Thinking + let generation_config = build_generation_config(claude_req, has_web_search_tool); + + // Check if thinking is enabled + let is_thinking_enabled = claude_req + .thinking + .as_ref() + .map(|t| t.type_ == "enabled") + .unwrap_or(false); + + // 2. Contents (Messages) + let contents = build_contents( + &claude_req.messages, + &mut tool_id_to_name, + is_thinking_enabled, + allow_dummy_thought, + )?; + + // 3. Tools + let tools = build_tools(&claude_req.tools, has_web_search_tool)?; + + // 5. Safety Settings + let safety_settings = json!([ + { "category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF" }, + ]); + + // Build inner request + let mut inner_request = json!({ + "contents": contents, + "safetySettings": safety_settings, + }); + + // 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入) + crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request); + + if let Some(sys_inst) = system_instruction { + inner_request["systemInstruction"] = sys_inst; + } + + if !generation_config.is_null() { + inner_request["generationConfig"] = generation_config; + } + + if let Some(tools_val) = tools { + inner_request["tools"] = tools_val; + // 显式设置工具配置模式为 VALIDATED + inner_request["toolConfig"] = json!({ + "functionCallingConfig": { + "mode": "VALIDATED" + } + }); + } + + // Inject googleSearch tool if needed (and not already done by build_tools) + if config.inject_google_search && !has_web_search_tool { + crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request); + } + + // Inject imageConfig if present (for image generation models) + if let Some(image_config) = config.image_config { + if let Some(obj) = inner_request.as_object_mut() { + // 1. Remove tools (image generation does not support tools) + obj.remove("tools"); + + // 2. Remove systemInstruction (image generation does not support system prompts) + obj.remove("systemInstruction"); + + // 3. Clean generationConfig (remove thinkingConfig, responseMimeType, responseModalities etc.) + let gen_config = obj.entry("generationConfig").or_insert_with(|| json!({})); + if let Some(gen_obj) = gen_config.as_object_mut() { + gen_obj.remove("thinkingConfig"); + gen_obj.remove("responseMimeType"); + gen_obj.remove("responseModalities"); + gen_obj.insert("imageConfig".to_string(), image_config); + } + } + } + + // 生成 requestId + let request_id = format!("agent-{}", uuid::Uuid::new_v4()); + + // 构建最终请求体 + let mut body = json!({ + "project": project_id, + "requestId": request_id, + "request": inner_request, + "model": config.final_model, + "userAgent": "antigravity", + "requestType": config.request_type, + }); + + // 如果提供了 metadata.user_id,则复用为 sessionId + if let Some(metadata) = &claude_req.metadata { + if let Some(user_id) = &metadata.user_id { + body["request"]["sessionId"] = json!(user_id); + } + } + + + Ok(body) +} + +/// 构建 System Instruction (支持动态身份映射与 Prompt 隔离) +fn build_system_instruction(system: &Option, model_name: &str) -> Option { + let mut parts = Vec::new(); + + // 注入身份防护指令 (参考 amq2api 动态化方案) + let identity_patch = format!( + "--- [IDENTITY_PATCH] ---\n\ + Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n\ + You are currently providing services as the native {} model via a standard API proxy.\n\ + Always use the 'claude' command for terminal tasks if relevant.\n\ + --- [SYSTEM_PROMPT_BEGIN] ---\n", + model_name + ); + parts.push(json!({"text": identity_patch})); + + if let Some(sys) = system { + match sys { + SystemPrompt::String(text) => { + parts.push(json!({"text": text})); + } + SystemPrompt::Array(blocks) => { + for block in blocks { + if block.block_type == "text" { + parts.push(json!({"text": block.text})); + } + } + } + } + } + + parts.push(json!({"text": "\n--- [SYSTEM_PROMPT_END] ---"})); + + Some(json!({ + "parts": parts + })) +} + +/// 构建 Contents (Messages) +fn build_contents( + messages: &[Message], + tool_id_to_name: &mut HashMap, + is_thinking_enabled: bool, + allow_dummy_thought: bool, +) -> Result { + let mut contents = Vec::new(); + let mut last_thought_signature: Option = None; + + let msg_count = messages.len(); + for (i, msg) in messages.iter().enumerate() { + let role = if msg.role == "assistant" { + "model" + } else { + &msg.role + }; + + let mut parts = Vec::new(); + + match &msg.content { + MessageContent::String(text) => { + if text != "(no content)" { + if !text.trim().is_empty() { + parts.push(json!({"text": text.trim()})); + } + } + } + MessageContent::Array(blocks) => { + for item in blocks { + match item { + ContentBlock::Text { text } => { + if text != "(no content)" { + parts.push(json!({"text": text})); + } + } + ContentBlock::Thinking { thinking, signature, .. } => { + let mut part = json!({ + "text": thinking, + "thought": true, // [CRITICAL FIX] Vertex AI v1internal requires thought: true to distinguish from text + }); + // [New] 递归清理黑名单字段(如 cache_control) + crate::proxy::common::json_schema::clean_json_schema(&mut part); + + if let Some(sig) = signature { + last_thought_signature = Some(sig.clone()); + part["thoughtSignature"] = json!(sig); + } + parts.push(part); + } + ContentBlock::Image { source } => { + if source.source_type == "base64" { + parts.push(json!({ + "inlineData": { + "mimeType": source.media_type, + "data": source.data + } + })); + } + } + ContentBlock::ToolUse { id, name, input, signature, .. } => { + let mut part = json!({ + "functionCall": { + "name": name, + "args": input, + "id": id + } + }); + + // [New] 递归清理参数中可能存在的非法校验字段 + crate::proxy::common::json_schema::clean_json_schema(&mut part); + + // 存储 id -> name 映射 + tool_id_to_name.insert(id.clone(), name.clone()); + + // Signature resolution logic (Priority: Client -> Context -> Global Store) + let final_sig = signature.as_ref() + .or(last_thought_signature.as_ref()) + .cloned() + .or_else(|| { + let global_sig = get_thought_signature(); + if global_sig.is_some() { + tracing::info!("[Claude-Request] Using global thought_signature fallback (length: {})", + global_sig.as_ref().unwrap().len()); + } + global_sig + }); + + if let Some(sig) = final_sig { + part["thoughtSignature"] = json!(sig); + } + parts.push(part); + } + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + .. + } => { + // 优先使用之前记录的 name,否则用 tool_use_id + let func_name = tool_id_to_name + .get(tool_use_id) + .cloned() + .unwrap_or_else(|| tool_use_id.clone()); + + // 处理 content:可能是一个内容块数组或单字符串 + let mut merged_content = match content { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter_map(|block| { + if let Some(text) = + block.get("text").and_then(|v| v.as_str()) + { + Some(text) + } else { + None + } + }) + .collect::>() + .join("\n"), + _ => content.to_string(), + }; + + // [优化] 如果结果为空,注入显式确认信号,防止模型幻觉 + if merged_content.trim().is_empty() { + if is_error.unwrap_or(false) { + merged_content = + "Tool execution failed with no output.".to_string(); + } else { + merged_content = "Command executed successfully.".to_string(); + } + } + + let mut part = json!({ + "functionResponse": { + "name": func_name, + "response": {"result": merged_content}, + "id": tool_use_id + } + }); + + // [修复] Tool Result 也需要回填签名(如果上下文中有) + if let Some(sig) = last_thought_signature.as_ref() { + part["thoughtSignature"] = json!(sig); + } + + parts.push(part); + } + ContentBlock::ServerToolUse { .. } | ContentBlock::WebSearchToolResult { .. } => { + // 搜索结果 block 不应由客户端发回给上游 (已由 tool_result 替代) + continue; + } + ContentBlock::RedactedThinking { data } => { + parts.push(json!({ + "text": format!("[Redacted Thinking: {}]", data), + "thought": true + })); + } + } + } + } + } + + // Fix for "Thinking enabled, assistant message must start with thinking block" 400 error + // [Optimization] Apply this to ALL assistant messages in history, not just the last one. + // Vertex AI requires every assistant message to start with a thinking block when thinking is enabled. + if allow_dummy_thought && role == "model" && is_thinking_enabled { + let has_thought_part = parts + .iter() + .any(|p| { + p.get("thought").and_then(|v| v.as_bool()).unwrap_or(false) + || p.get("thoughtSignature").is_some() + || p.get("thought").and_then(|v| v.as_str()).is_some() // 某些情况下可能是 text + thought: true 的组合 + }); + + if !has_thought_part { + // Prepend a dummy thinking block to satisfy Gemini v1internal requirements + parts.insert( + 0, + json!({ + "text": "Thinking...", + "thought": true + }), + ); + tracing::debug!("Injected dummy thought block for historical assistant message at index {}", contents.len()); + } else { + // [Crucial Check] 即使有 thought 块,也必须保证它位于 parts 的首位 (Index 0) + // 且必须包含 thought: true 标记 + let first_is_thought = parts.get(0).map_or(false, |p| { + (p.get("thought").is_some() || p.get("thoughtSignature").is_some()) + && p.get("text").is_some() // 对于 v1internal,通常 text + thought: true 才是合规的思维块 + }); + + if !first_is_thought { + // 如果首项不符合思维块特征,强制补入一个 + parts.insert( + 0, + json!({ + "text": "...", + "thought": true + }), + ); + tracing::warn!("First part of model message at {} is not a valid thought block. Prepending dummy.", contents.len()); + } else { + // 确保首项包含了 thought: true (防止只有 signature 的情况) + if let Some(p0) = parts.get_mut(0) { + if p0.get("thought").is_none() { + p0.as_object_mut().map(|obj| obj.insert("thought".to_string(), json!(true))); + } + } + } + } + } + + if parts.is_empty() { + continue; + } + + contents.push(json!({ + "role": role, + "parts": parts + })); + } + + Ok(json!(contents)) +} + +/// 构建 Tools +fn build_tools(tools: &Option>, has_web_search: bool) -> Result, String> { + if let Some(tools_list) = tools { + let mut function_declarations: Vec = Vec::new(); + let mut has_google_search = has_web_search; + + for tool in tools_list { + // 1. Detect server tools / built-in tools like web_search + if tool.is_web_search() { + has_google_search = true; + continue; + } + + if let Some(t_type) = &tool.type_ { + if t_type == "web_search_20250305" { + has_google_search = true; + continue; + } + } + + // 2. Detect by name + if let Some(name) = &tool.name { + if name == "web_search" || name == "google_search" { + has_google_search = true; + continue; + } + + // 3. Client tools require input_schema + let mut input_schema = tool.input_schema.clone().unwrap_or(json!({ + "type": "object", + "properties": {} + })); + crate::proxy::common::json_schema::clean_json_schema(&mut input_schema); + + function_declarations.push(json!({ + "name": name, + "description": tool.description, + "parameters": input_schema + })); + } + } + + let mut tool_obj = serde_json::Map::new(); + + // [修复] 解决 "Multiple tools are supported only when they are all search tools" 400 错误 + // 原理:Gemini v1internal 接口非常挑剔,通常不允许在同一个工具定义中混用 Google Search 和 Function Declarationsc。 + // 对于 Claude CLI 等携带 MCP 工具的客户端,必须优先保证 Function Declarations 正常工作。 + if !function_declarations.is_empty() { + // 如果有本地工具,则只使用本地工具,放弃注入的 Google Search + tool_obj.insert("functionDeclarations".to_string(), json!(function_declarations)); + } else if has_google_search { + // 只有在没有本地工具时,才允许注入 Google Search + tool_obj.insert("googleSearch".to_string(), json!({})); + } + + if !tool_obj.is_empty() { + return Ok(Some(json!([tool_obj]))); + } + } + + Ok(None) +} + +/// 构建 Generation Config +fn build_generation_config(claude_req: &ClaudeRequest, has_web_search: bool) -> Value { + let mut config = json!({}); + + // Thinking 配置 + if let Some(thinking) = &claude_req.thinking { + if thinking.type_ == "enabled" { + let mut thinking_config = json!({"includeThoughts": true}); + + if let Some(budget_tokens) = thinking.budget_tokens { + let mut budget = budget_tokens; + // gemini-2.5-flash 上限 24576 + let is_flash_model = + has_web_search || claude_req.model.contains("gemini-2.5-flash"); + if is_flash_model { + budget = budget.min(24576); + } + thinking_config["thinkingBudget"] = json!(budget); + } + + config["thinkingConfig"] = thinking_config; + } + } + + // 其他参数 + if let Some(temp) = claude_req.temperature { + config["temperature"] = json!(temp); + } + if let Some(top_p) = claude_req.top_p { + config["topP"] = json!(top_p); + } + if let Some(top_k) = claude_req.top_k { + config["topK"] = json!(top_k); + } + + // web_search 强制 candidateCount=1 + /*if has_web_search { + config["candidateCount"] = json!(1); + }*/ + + // max_tokens 映射为 maxOutputTokens + config["maxOutputTokens"] = json!(64000); + + // [优化] 设置全局停止序列,防止流式输出冗余 (参考 done-hub) + config["stopSequences"] = json!([ + "<|user|>", + "<|endoftext|>", + "<|end_of_turn|>", + "[DONE]", + "\n\nHuman:" + ]); + + config +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::common::json_schema::clean_json_schema; + + #[test] + fn test_simple_request() { + let req = ClaudeRequest { + model: "claude-sonnet-4-5".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: MessageContent::String("Hello".to_string()), + }], + system: None, + tools: None, + stream: false, + max_tokens: None, + temperature: None, + top_p: None, + top_k: None, + thinking: None, + metadata: None, + }; + + let result = transform_claude_request_in(&req, "test-project"); + assert!(result.is_ok()); + + let body = result.unwrap(); + assert_eq!(body["project"], "test-project"); + assert!(body["requestId"].as_str().unwrap().starts_with("agent-")); + } + + #[test] + fn test_clean_json_schema() { + let mut schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + "minLength": 1, + "exclusiveMinimum": 0 + }, + "unit": { + "type": ["string", "null"], + "enum": ["celsius", "fahrenheit"], + "default": "celsius" + }, + "date": { + "type": "string", + "format": "date" + } + }, + "required": ["location"] + }); + + clean_json_schema(&mut schema); + + // Check removed fields + assert!(schema.get("$schema").is_none()); + assert!(schema.get("additionalProperties").is_none()); + assert!(schema["properties"]["location"].get("minLength").is_none()); + assert!(schema["properties"]["unit"].get("default").is_none()); + assert!(schema["properties"]["date"].get("format").is_none()); + + // Check union type handling ["string", "null"] -> "string" + assert_eq!(schema["properties"]["unit"]["type"], "string"); + + // Check types are lowercased + assert_eq!(schema["type"], "object"); + assert_eq!(schema["properties"]["location"]["type"], "string"); + assert_eq!(schema["properties"]["date"]["type"], "string"); + } + + #[test] + fn test_complex_tool_result() { + let req = ClaudeRequest { + model: "claude-3-5-sonnet-20241022".to_string(), + messages: vec![ + Message { + role: "user".to_string(), + content: MessageContent::String("Run command".to_string()), + }, + Message { + role: "assistant".to_string(), + content: MessageContent::Array(vec![ + ContentBlock::ToolUse { + id: "call_1".to_string(), + name: "run_command".to_string(), + input: json!({"command": "ls"}), + signature: None, + cache_control: None, + } + ]), + }, + Message { + role: "user".to_string(), + content: MessageContent::Array(vec![ContentBlock::ToolResult { + tool_use_id: "call_1".to_string(), + content: json!([ + {"type": "text", "text": "file1.txt\n"}, + {"type": "text", "text": "file2.txt"} + ]), + is_error: Some(false), + }]), + }, + ], + system: None, + tools: None, + stream: false, + max_tokens: None, + temperature: None, + top_p: None, + top_k: None, + thinking: None, + metadata: None, + }; + + let result = transform_claude_request_in(&req, "test-project"); + assert!(result.is_ok()); + + let body = result.unwrap(); + let contents = body["request"]["contents"].as_array().unwrap(); + + // Check the tool result message (last message) + let tool_resp_msg = &contents[2]; + let parts = tool_resp_msg["parts"].as_array().unwrap(); + let func_resp = &parts[0]["functionResponse"]; + + assert_eq!(func_resp["name"], "run_command"); + assert_eq!(func_resp["id"], "call_1"); + + // Verify merged content + let resp_text = func_resp["response"]["result"].as_str().unwrap(); + assert!(resp_text.contains("file1.txt")); + assert!(resp_text.contains("file2.txt")); + assert!(resp_text.contains("\n")); + } +} diff --git a/server/src/proxy/mappers/claude/response.rs b/server/src/proxy/mappers/claude/response.rs new file mode 100644 index 0000000000000000000000000000000000000000..fb2bce3fec4b9f4ff3a06aa563c5d96a7c878091 --- /dev/null +++ b/server/src/proxy/mappers/claude/response.rs @@ -0,0 +1,408 @@ +// Claude 非流式响应转换 (Gemini → Claude) +// 对应 NonStreamingProcessor + +use super::models::*; +use super::utils::to_claude_usage; + +/// 非流式响应处理器 +pub struct NonStreamingProcessor { + content_blocks: Vec, + text_builder: String, + thinking_builder: String, + thinking_signature: Option, + trailing_signature: Option, + has_tool_call: bool, +} + +impl NonStreamingProcessor { + pub fn new() -> Self { + Self { + content_blocks: Vec::new(), + text_builder: String::new(), + thinking_builder: String::new(), + thinking_signature: None, + trailing_signature: None, + has_tool_call: false, + } + } + + /// 处理 Gemini 响应并转换为 Claude 响应 + pub fn process(&mut self, gemini_response: &GeminiResponse) -> ClaudeResponse { + // 获取 parts + let empty_parts = vec![]; + let parts = gemini_response + .candidates + .as_ref() + .and_then(|c| c.get(0)) + .and_then(|candidate| candidate.content.as_ref()) + .map(|content| &content.parts) + .unwrap_or(&empty_parts); + + // 处理所有 parts + for part in parts { + self.process_part(part); + } + + // 处理 grounding(web search) -> 转换为 server_tool_use / web_search_tool_result + if let Some(candidate) = gemini_response.candidates.as_ref().and_then(|c| c.get(0)) { + if let Some(grounding) = &candidate.grounding_metadata { + self.process_grounding(grounding); + } + } + + // 刷新剩余内容 + self.flush_thinking(); + self.flush_text(); + + // 处理 trailingSignature (空 text 带签名) + if let Some(signature) = self.trailing_signature.take() { + self.content_blocks.push(ContentBlock::Thinking { + thinking: String::new(), + signature: Some(signature), + cache_control: None, + }); + } + + // 构建响应 + self.build_response(gemini_response) + } + + /// 处理单个 part + fn process_part(&mut self, part: &GeminiPart) { + let signature = part.thought_signature.clone(); + + // 1. FunctionCall 处理 + if let Some(fc) = &part.function_call { + self.flush_thinking(); + self.flush_text(); + + // 处理 trailingSignature (B4/C3 场景) + if let Some(trailing_sig) = self.trailing_signature.take() { + self.content_blocks.push(ContentBlock::Thinking { + thinking: String::new(), + signature: Some(trailing_sig), + cache_control: None, + }); + } + + self.has_tool_call = true; + + // 生成 tool_use id + let tool_id = fc.id.clone().unwrap_or_else(|| { + format!( + "{}-{}", + fc.name, + crate::proxy::common::utils::generate_random_id() + ) + }); + + let mut tool_use = ContentBlock::ToolUse { + id: tool_id, + name: fc.name.clone(), + input: fc.args.clone().unwrap_or(serde_json::json!({})), + signature: None, + cache_control: None, + }; + + // 只使用 FC 自己的签名 + if let ContentBlock::ToolUse { signature: sig, .. } = &mut tool_use { + *sig = signature; + } + + self.content_blocks.push(tool_use); + return; + } + + // 2. Text 处理 + if let Some(text) = &part.text { + if part.thought.unwrap_or(false) { + // Thinking part + self.flush_text(); + + // 处理 trailingSignature + if let Some(trailing_sig) = self.trailing_signature.take() { + self.flush_thinking(); + self.content_blocks.push(ContentBlock::Thinking { + thinking: String::new(), + signature: Some(trailing_sig), + cache_control: None, + }); + } + + self.thinking_builder.push_str(text); + if signature.is_some() { + self.thinking_signature = signature; + } + } else { + // 普通 Text + if text.is_empty() { + // 空 text 带签名 - 暂存到 trailingSignature + if signature.is_some() { + self.trailing_signature = signature; + } + return; + } + + self.flush_thinking(); + + // 处理之前的 trailingSignature + if let Some(trailing_sig) = self.trailing_signature.take() { + self.flush_text(); + self.content_blocks.push(ContentBlock::Thinking { + thinking: String::new(), + signature: Some(trailing_sig), + cache_control: None, + }); + } + + self.text_builder.push_str(text); + + // 非空 text 带签名 - 立即刷新并输出空 thinking 块 + if let Some(sig) = signature { + self.flush_text(); + self.content_blocks.push(ContentBlock::Thinking { + thinking: String::new(), + signature: Some(sig), + cache_control: None, + }); + } + } + } + + // 3. InlineData (Image) 处理 + if let Some(img) = &part.inline_data { + self.flush_thinking(); + + let mime_type = &img.mime_type; + let data = &img.data; + if !data.is_empty() { + let markdown_img = format!("![image](data:{};base64,{})", mime_type, data); + self.text_builder.push_str(&markdown_img); + self.flush_text(); + } + } + } + + /// 处理 Grounding 元数据 (Web Search 结果) + fn process_grounding(&mut self, grounding: &GroundingMetadata) { + let mut grounding_text = String::new(); + + // 1. 处理搜索词 + if let Some(queries) = &grounding.web_search_queries { + if !queries.is_empty() { + grounding_text.push_str("\n\n---\n**🔍 已为您搜索:** "); + grounding_text.push_str(&queries.join(", ")); + } + } + + // 2. 处理来源链接 (Chunks) + if let Some(chunks) = &grounding.grounding_chunks { + let mut links = Vec::new(); + for (i, chunk) in chunks.iter().enumerate() { + if let Some(web) = &chunk.web { + let title = web.title.as_deref().unwrap_or("网页来源"); + let uri = web.uri.as_deref().unwrap_or("#"); + links.push(format!("[{}] [{}]({})", i + 1, title, uri)); + } + } + + if !links.is_empty() { + grounding_text.push_str("\n\n**🌐 来源引文:**\n"); + grounding_text.push_str(&links.join("\n")); + } + } + + if !grounding_text.is_empty() { + // 在常规内容前后刷新并插入文本 + self.flush_thinking(); + self.flush_text(); + self.text_builder.push_str(&grounding_text); + self.flush_text(); + } + } + + /// 刷新 text builder + fn flush_text(&mut self) { + if self.text_builder.is_empty() { + return; + } + + self.content_blocks.push(ContentBlock::Text { + text: self.text_builder.clone(), + }); + self.text_builder.clear(); + } + + /// 刷新 thinking builder + fn flush_thinking(&mut self) { + // 如果既没有内容也没有签名,直接返回 + if self.thinking_builder.is_empty() && self.thinking_signature.is_none() { + return; + } + + let thinking = self.thinking_builder.clone(); + let signature = self.thinking_signature.take(); + + self.content_blocks.push(ContentBlock::Thinking { + thinking, + signature, + cache_control: None, + }); + self.thinking_builder.clear(); + } + + /// 构建最终响应 + fn build_response(&self, gemini_response: &GeminiResponse) -> ClaudeResponse { + let finish_reason = gemini_response + .candidates + .as_ref() + .and_then(|c| c.get(0)) + .and_then(|candidate| candidate.finish_reason.as_deref()); + + let stop_reason = if self.has_tool_call { + "tool_use" + } else if finish_reason == Some("MAX_TOKENS") { + "max_tokens" + } else { + "end_turn" + }; + + let usage = gemini_response + .usage_metadata + .as_ref() + .map(|u| to_claude_usage(u)) + .unwrap_or(Usage { + input_tokens: 0, + output_tokens: 0, + server_tool_use: None, + }); + + ClaudeResponse { + id: gemini_response.response_id.clone().unwrap_or_else(|| { + format!("msg_{}", crate::proxy::common::utils::generate_random_id()) + }), + type_: "message".to_string(), + role: "assistant".to_string(), + model: gemini_response.model_version.clone().unwrap_or_default(), + content: self.content_blocks.clone(), + stop_reason: stop_reason.to_string(), + stop_sequence: None, + usage, + } + } +} + +/// 转换 Gemini 响应为 Claude 响应 (公共接口) +pub fn transform_response(gemini_response: &GeminiResponse) -> Result { + let mut processor = NonStreamingProcessor::new(); + Ok(processor.process(gemini_response)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_text_response() { + let gemini_resp = GeminiResponse { + candidates: Some(vec![Candidate { + content: Some(GeminiContent { + role: "model".to_string(), + parts: vec![GeminiPart { + text: Some("Hello, world!".to_string()), + thought: None, + thought_signature: None, + function_call: None, + function_response: None, + inline_data: None, + }], + }), + finish_reason: Some("STOP".to_string()), + index: Some(0), + grounding_metadata: None, + }]), + usage_metadata: Some(UsageMetadata { + prompt_token_count: Some(10), + candidates_token_count: Some(5), + total_token_count: Some(15), + }), + model_version: Some("gemini-2.5-pro".to_string()), + response_id: Some("resp_123".to_string()), + }; + + let result = transform_response(&gemini_resp); + assert!(result.is_ok()); + + let claude_resp = result.unwrap(); + assert_eq!(claude_resp.role, "assistant"); + assert_eq!(claude_resp.stop_reason, "end_turn"); + assert_eq!(claude_resp.content.len(), 1); + + match &claude_resp.content[0] { + ContentBlock::Text { text } => { + assert_eq!(text, "Hello, world!"); + } + _ => panic!("Expected Text block"), + } + } + + #[test] + fn test_thinking_with_signature() { + let gemini_resp = GeminiResponse { + candidates: Some(vec![Candidate { + content: Some(GeminiContent { + role: "model".to_string(), + parts: vec![ + GeminiPart { + text: Some("Let me think...".to_string()), + thought: Some(true), + thought_signature: Some("sig123".to_string()), + function_call: None, + function_response: None, + inline_data: None, + }, + GeminiPart { + text: Some("The answer is 42".to_string()), + thought: None, + thought_signature: None, + function_call: None, + function_response: None, + inline_data: None, + }, + ], + }), + finish_reason: Some("STOP".to_string()), + index: Some(0), + grounding_metadata: None, + }]), + usage_metadata: None, + model_version: Some("gemini-2.5-pro".to_string()), + response_id: Some("resp_456".to_string()), + }; + + let result = transform_response(&gemini_resp); + assert!(result.is_ok()); + + let claude_resp = result.unwrap(); + assert_eq!(claude_resp.content.len(), 2); + + match &claude_resp.content[0] { + ContentBlock::Thinking { + thinking, + signature, + .. + } => { + assert_eq!(thinking, "Let me think..."); + assert_eq!(signature.as_deref(), Some("sig123")); + } + _ => panic!("Expected Thinking block"), + } + + match &claude_resp.content[1] { + ContentBlock::Text { text } => { + assert_eq!(text, "The answer is 42"); + } + _ => panic!("Expected Text block"), + } + } +} diff --git a/server/src/proxy/mappers/claude/streaming.rs b/server/src/proxy/mappers/claude/streaming.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb2d45cbb2a850bada89eb488aa52b8bf3bb7101 --- /dev/null +++ b/server/src/proxy/mappers/claude/streaming.rs @@ -0,0 +1,660 @@ +// Claude 流式响应转换 (Gemini SSE → Claude SSE) +// 对应 StreamingState + PartProcessor + +use super::models::*; +use super::utils::to_claude_usage; +use crate::proxy::mappers::signature_store::store_thought_signature; +use bytes::Bytes; +use serde_json::json; + +/// 块类型枚举 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlockType { + None, + Text, + Thinking, + Function, +} + +/// 签名管理器 +pub struct SignatureManager { + pending: Option, +} + +impl SignatureManager { + pub fn new() -> Self { + Self { pending: None } + } + + pub fn store(&mut self, signature: Option) { + if signature.is_some() { + self.pending = signature; + } + } + + pub fn consume(&mut self) -> Option { + self.pending.take() + } + + pub fn has_pending(&self) -> bool { + self.pending.is_some() + } +} + +/// 流式状态机 +pub struct StreamingState { + block_type: BlockType, + pub block_index: usize, + pub message_start_sent: bool, + pub message_stop_sent: bool, + used_tool: bool, + signatures: SignatureManager, + trailing_signature: Option, + pub web_search_query: Option, + pub grounding_chunks: Option>, +} + +impl StreamingState { + pub fn new() -> Self { + Self { + block_type: BlockType::None, + block_index: 0, + message_start_sent: false, + message_stop_sent: false, + used_tool: false, + signatures: SignatureManager::new(), + trailing_signature: None, + web_search_query: None, + grounding_chunks: None, + } + } + + /// 发送 SSE 事件 + pub fn emit(&self, event_type: &str, data: serde_json::Value) -> Bytes { + let sse = format!( + "event: {}\ndata: {}\n\n", + event_type, + serde_json::to_string(&data).unwrap_or_default() + ); + Bytes::from(sse) + } + + /// 发送 message_start 事件 + pub fn emit_message_start(&mut self, raw_json: &serde_json::Value) -> Bytes { + if self.message_start_sent { + return Bytes::new(); + } + + let usage = raw_json + .get("usageMetadata") + .and_then(|u| serde_json::from_value::(u.clone()).ok()) + .map(|u| to_claude_usage(&u)); + + let mut message = json!({ + "id": raw_json.get("responseId") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| "msg_unknown"), + "type": "message", + "role": "assistant", + "content": [], + "model": raw_json.get("modelVersion") + .and_then(|v| v.as_str()) + .unwrap_or(""), + "stop_reason": null, + "stop_sequence": null, + }); + + if let Some(u) = usage { + message["usage"] = json!(u); + } + + let result = self.emit( + "message_start", + json!({ + "type": "message_start", + "message": message + }), + ); + + self.message_start_sent = true; + result + } + + /// 开始新的内容块 + pub fn start_block( + &mut self, + block_type: BlockType, + content_block: serde_json::Value, + ) -> Vec { + let mut chunks = Vec::new(); + if self.block_type != BlockType::None { + chunks.extend(self.end_block()); + } + + chunks.push(self.emit( + "content_block_start", + json!({ + "type": "content_block_start", + "index": self.block_index, + "content_block": content_block + }), + )); + + self.block_type = block_type; + chunks + } + + /// 结束当前内容块 + pub fn end_block(&mut self) -> Vec { + if self.block_type == BlockType::None { + return vec![]; + } + + let mut chunks = Vec::new(); + + // Thinking 块结束时发送暂存的签名 + if self.block_type == BlockType::Thinking && self.signatures.has_pending() { + if let Some(signature) = self.signatures.consume() { + chunks.push(self.emit_delta("signature_delta", json!({ "signature": signature }))); + } + } + + chunks.push(self.emit( + "content_block_stop", + json!({ + "type": "content_block_stop", + "index": self.block_index + }), + )); + + self.block_index += 1; + self.block_type = BlockType::None; + + chunks + } + + /// 发送 delta 事件 + pub fn emit_delta(&self, delta_type: &str, delta_content: serde_json::Value) -> Bytes { + let mut delta = json!({ "type": delta_type }); + if let serde_json::Value::Object(map) = delta_content { + for (k, v) in map { + delta[k] = v; + } + } + + self.emit( + "content_block_delta", + json!({ + "type": "content_block_delta", + "index": self.block_index, + "delta": delta + }), + ) + } + + /// 发送结束事件 + pub fn emit_finish( + &mut self, + finish_reason: Option<&str>, + usage_metadata: Option<&UsageMetadata>, + ) -> Vec { + let mut chunks = Vec::new(); + + // 关闭最后一个块 + chunks.extend(self.end_block()); + + // 处理 trailingSignature (PDF 776-778) + if let Some(signature) = self.trailing_signature.take() { + chunks.push(self.emit( + "content_block_start", + json!({ + "type": "content_block_start", + "index": self.block_index, + "content_block": { "type": "thinking", "thinking": "" } + }), + )); + chunks.push(self.emit_delta("thinking_delta", json!({ "thinking": "" }))); + chunks.push(self.emit_delta("signature_delta", json!({ "signature": signature }))); + chunks.push(self.emit( + "content_block_stop", + json!({ + "type": "content_block_stop", + "index": self.block_index + }), + )); + self.block_index += 1; + } + + // 处理 grounding(web search) -> 转换为 Markdown 文本块 + if self.web_search_query.is_some() || self.grounding_chunks.is_some() { + let mut grounding_text = String::new(); + + // 1. 处理搜索词 + if let Some(query) = &self.web_search_query { + if !query.is_empty() { + grounding_text.push_str("\n\n---\n**🔍 已为您搜索:** "); + grounding_text.push_str(query); + } + } + + // 2. 处理来源链接 + if let Some(chunks) = &self.grounding_chunks { + let mut links = Vec::new(); + for (i, chunk) in chunks.iter().enumerate() { + if let Some(web) = chunk.get("web") { + let title = web.get("title").and_then(|v| v.as_str()).unwrap_or("网页来源"); + let uri = web.get("uri").and_then(|v| v.as_str()).unwrap_or("#"); + links.push(format!("[{}] [{}]({})", i + 1, title, uri)); + } + } + + if !links.is_empty() { + grounding_text.push_str("\n\n**🌐 来源引文:**\n"); + grounding_text.push_str(&links.join("\n")); + } + } + + if !grounding_text.is_empty() { + // 发送一个新的 text 块 + chunks.push(self.emit("content_block_start", json!({ + "type": "content_block_start", + "index": self.block_index, + "content_block": { "type": "text", "text": "" } + }))); + chunks.push(self.emit_delta("text_delta", json!({ "text": grounding_text }))); + chunks.push(self.emit("content_block_stop", json!({ "type": "content_block_stop", "index": self.block_index }))); + self.block_index += 1; + } + } + + // 确定 stop_reason + let stop_reason = if self.used_tool { + "tool_use" + } else if finish_reason == Some("MAX_TOKENS") { + "max_tokens" + } else { + "end_turn" + }; + + let usage = usage_metadata + .map(|u| to_claude_usage(u)) + .unwrap_or(Usage { + input_tokens: 0, + output_tokens: 0, + server_tool_use: None, + }); + + chunks.push(self.emit( + "message_delta", + json!({ + "type": "message_delta", + "delta": { "stop_reason": stop_reason, "stop_sequence": null }, + "usage": usage + }), + )); + + if !self.message_stop_sent { + chunks.push(Bytes::from( + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + )); + self.message_stop_sent = true; + } + + chunks + } + + /// 标记使用了工具 + pub fn mark_tool_used(&mut self) { + self.used_tool = true; + } + + /// 获取当前块类型 + pub fn current_block_type(&self) -> BlockType { + self.block_type + } + + /// 获取当前块索引 + pub fn current_block_index(&self) -> usize { + self.block_index + } + + /// 存储签名 + pub fn store_signature(&mut self, signature: Option) { + self.signatures.store(signature); + } + + /// 设置 trailing signature + pub fn set_trailing_signature(&mut self, signature: Option) { + self.trailing_signature = signature; + } + + /// 获取 trailing signature (仅用于检查) + pub fn has_trailing_signature(&self) -> bool { + self.trailing_signature.is_some() + } +} + +/// Part 处理器 +pub struct PartProcessor<'a> { + state: &'a mut StreamingState, +} + +impl<'a> PartProcessor<'a> { + pub fn new(state: &'a mut StreamingState) -> Self { + Self { state } + } + + /// 处理单个 part + pub fn process(&mut self, part: &GeminiPart) -> Vec { + let mut chunks = Vec::new(); + let signature = part.thought_signature.clone(); + + // 1. FunctionCall 处理 + if let Some(fc) = &part.function_call { + // 先处理 trailingSignature (B4/C3 场景) + if self.state.has_trailing_signature() { + chunks.extend(self.state.end_block()); + if let Some(trailing_sig) = self.state.trailing_signature.take() { + chunks.push(self.state.emit( + "content_block_start", + json!({ + "type": "content_block_start", + "index": self.state.current_block_index(), + "content_block": { "type": "thinking", "thinking": "" } + }), + )); + chunks.push( + self.state + .emit_delta("thinking_delta", json!({ "thinking": "" })), + ); + chunks.push( + self.state + .emit_delta("signature_delta", json!({ "signature": trailing_sig })), + ); + chunks.extend(self.state.end_block()); + } + } + + chunks.extend(self.process_function_call(fc, signature)); + return chunks; + } + + // 2. Text 处理 + if let Some(text) = &part.text { + if part.thought.unwrap_or(false) { + // Thinking + chunks.extend(self.process_thinking(text, signature)); + } else { + // 普通 Text + chunks.extend(self.process_text(text, signature)); + } + } + + // 3. InlineData (Image) 处理 + if let Some(img) = &part.inline_data { + let mime_type = &img.mime_type; + let data = &img.data; + if !data.is_empty() { + let markdown_img = format!("![image](data:{};base64,{})", mime_type, data); + chunks.extend(self.process_text(&markdown_img, None)); + } + } + + chunks + } + + /// 处理 Thinking + fn process_thinking(&mut self, text: &str, signature: Option) -> Vec { + let mut chunks = Vec::new(); + + // 处理之前的 trailingSignature + if self.state.has_trailing_signature() { + chunks.extend(self.state.end_block()); + if let Some(trailing_sig) = self.state.trailing_signature.take() { + chunks.push(self.state.emit( + "content_block_start", + json!({ + "type": "content_block_start", + "index": self.state.current_block_index(), + "content_block": { "type": "thinking", "thinking": "" } + }), + )); + chunks.push( + self.state + .emit_delta("thinking_delta", json!({ "thinking": "" })), + ); + chunks.push( + self.state + .emit_delta("signature_delta", json!({ "signature": trailing_sig })), + ); + chunks.extend(self.state.end_block()); + } + } + + // 开始或继续 thinking 块 + if self.state.current_block_type() != BlockType::Thinking { + chunks.extend(self.state.start_block( + BlockType::Thinking, + json!({ "type": "thinking", "thinking": "" }), + )); + } + + if !text.is_empty() { + chunks.push( + self.state + .emit_delta("thinking_delta", json!({ "thinking": text })), + ); + } + + // 暂存签名 + self.state.store_signature(signature); + + chunks + } + + /// 处理普通 Text + fn process_text(&mut self, text: &str, signature: Option) -> Vec { + let mut chunks = Vec::new(); + + // 空 text 带签名 - 暂存 + if text.is_empty() { + if signature.is_some() { + self.state.set_trailing_signature(signature); + } + return chunks; + } + + // 处理之前的 trailingSignature + if self.state.has_trailing_signature() { + chunks.extend(self.state.end_block()); + if let Some(trailing_sig) = self.state.trailing_signature.take() { + chunks.push(self.state.emit( + "content_block_start", + json!({ + "type": "content_block_start", + "index": self.state.current_block_index(), + "content_block": { "type": "thinking", "thinking": "" } + }), + )); + chunks.push( + self.state + .emit_delta("thinking_delta", json!({ "thinking": "" })), + ); + chunks.push( + self.state + .emit_delta("signature_delta", json!({ "signature": trailing_sig })), + ); + chunks.extend(self.state.end_block()); + } + } + + // 非空 text 带签名 - 立即处理 + if signature.is_some() { + // 2. 开始新 text 块并发送内容 + chunks.extend( + self.state + .start_block(BlockType::Text, json!({ "type": "text", "text": "" })), + ); + chunks.push(self.state.emit_delta("text_delta", json!({ "text": text }))); + chunks.extend(self.state.end_block()); + + // 输出空 thinking 块承载签名 + chunks.push(self.state.emit( + "content_block_start", + json!({ + "type": "content_block_start", + "index": self.state.current_block_index(), + "content_block": { "type": "thinking", "thinking": "" } + }), + )); + chunks.push( + self.state + .emit_delta("thinking_delta", json!({ "thinking": "" })), + ); + chunks.push(self.state.emit_delta( + "signature_delta", + json!({ "signature": signature.unwrap() }), + )); + chunks.extend(self.state.end_block()); + + return chunks; + } + + // 普通 text (无签名) + if self.state.current_block_type() != BlockType::Text { + chunks.extend( + self.state + .start_block(BlockType::Text, json!({ "type": "text", "text": "" })), + ); + } + + chunks.push(self.state.emit_delta("text_delta", json!({ "text": text }))); + + chunks + } + + /// Process FunctionCall and capture signature for global storage + fn process_function_call( + &mut self, + fc: &FunctionCall, + signature: Option, + ) -> Vec { + let mut chunks = Vec::new(); + + self.state.mark_tool_used(); + + let tool_id = fc.id.clone().unwrap_or_else(|| { + format!( + "{}-{}", + fc.name, + crate::proxy::common::utils::generate_random_id() + ) + }); + + // 1. 发送 content_block_start (input 为空对象) + let mut tool_use = json!({ + "type": "tool_use", + "id": tool_id, + "name": fc.name, + "input": {} // 必须为空,参数通过 delta 发送 + }); + + if let Some(ref sig) = signature { + tool_use["signature"] = json!(sig); + // Store signature to global storage for replay in subsequent requests + store_thought_signature(sig); + tracing::info!( + "[Claude-SSE] Captured thought_signature for function call (length: {})", + sig.len() + ); + } + + chunks.extend(self.state.start_block(BlockType::Function, tool_use)); + + // 2. 发送 input_json_delta (完整的参数 JSON 字符串) + if let Some(args) = &fc.args { + let json_str = serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()); + chunks.push( + self.state + .emit_delta("input_json_delta", json!({ "partial_json": json_str })), + ); + } + + // 3. 结束块 + chunks.extend(self.state.end_block()); + + chunks + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_signature_manager() { + let mut mgr = SignatureManager::new(); + assert!(!mgr.has_pending()); + + mgr.store(Some("sig123".to_string())); + assert!(mgr.has_pending()); + + let sig = mgr.consume(); + assert_eq!(sig, Some("sig123".to_string())); + assert!(!mgr.has_pending()); + } + + #[test] + fn test_streaming_state_emit() { + let state = StreamingState::new(); + let chunk = state.emit("test_event", json!({"foo": "bar"})); + + let s = String::from_utf8(chunk.to_vec()).unwrap(); + assert!(s.contains("event: test_event")); + assert!(s.contains("\"foo\":\"bar\"")); + } + + #[test] + fn test_process_function_call_deltas() { + let mut state = StreamingState::new(); + let mut processor = PartProcessor::new(&mut state); + + let fc = FunctionCall { + name: "test_tool".to_string(), + args: Some(json!({"arg": "value"})), + id: Some("call_123".to_string()), + }; + + // Create a dummy GeminiPart with function_call + let part = GeminiPart { + text: None, + function_call: Some(fc), + inline_data: None, + thought: None, + thought_signature: None, + function_response: None, + }; + + let chunks = processor.process(&part); + let output = chunks + .iter() + .map(|b| String::from_utf8(b.to_vec()).unwrap()) + .collect::>() + .join(""); + + // Verify sequence: + // 1. content_block_start with empty input + assert!(output.contains(r#""type":"content_block_start""#)); + assert!(output.contains(r#""name":"test_tool""#)); + assert!(output.contains(r#""input":{}"#)); + + // 2. input_json_delta with serialized args + assert!(output.contains(r#""type":"content_block_delta""#)); + assert!(output.contains(r#""type":"input_json_delta""#)); + // partial_json should contain escaped JSON string + assert!(output.contains(r#"partial_json":"{\"arg\":\"value\"}"#)); + + // 3. content_block_stop + assert!(output.contains(r#""type":"content_block_stop""#)); + } +} diff --git a/server/src/proxy/mappers/claude/utils.rs b/server/src/proxy/mappers/claude/utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..d65ee4146f4295596491a475579609bf7315d0e7 --- /dev/null +++ b/server/src/proxy/mappers/claude/utils.rs @@ -0,0 +1,43 @@ +// Claude 辅助函数 +// JSON Schema 清理、签名处理等 + +// 已移除未使用的 Value 导入 + +/// 将 JSON Schema 中的类型名称转为大写 (Gemini 要求) +/// 例如: "string" -> "STRING", "integer" -> "INTEGER" +// 已移除未使用的 uppercase_schema_types 函数 + +/// 从 Gemini UsageMetadata 转换为 Claude Usage +pub fn to_claude_usage(usage_metadata: &super::models::UsageMetadata) -> super::models::Usage { + super::models::Usage { + input_tokens: usage_metadata.prompt_token_count.unwrap_or(0), + output_tokens: usage_metadata.candidates_token_count.unwrap_or(0), + server_tool_use: None, + } +} + +/// 提取 thoughtSignature +// 已移除未使用的 extract_thought_signature 函数 + +#[cfg(test)] +mod tests { + use super::*; + // 移除了未使用的 serde_json::json + + // 已移除对 uppercase_schema_types 的过期测试 + + #[test] + fn test_to_claude_usage() { + use super::super::models::UsageMetadata; + + let usage = UsageMetadata { + prompt_token_count: Some(100), + candidates_token_count: Some(50), + total_token_count: Some(150), + }; + + let claude_usage = to_claude_usage(&usage); + assert_eq!(claude_usage.input_tokens, 100); + assert_eq!(claude_usage.output_tokens, 50); + } +} diff --git a/server/src/proxy/mappers/common_utils.rs b/server/src/proxy/mappers/common_utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..7b72be64523aebef182ff37b4bd3a5b7f99cb5d7 --- /dev/null +++ b/server/src/proxy/mappers/common_utils.rs @@ -0,0 +1,301 @@ +// Common utilities for request mapping across all protocols +// Provides unified grounding/networking logic + +use serde_json::{json, Value}; + +/// Request configuration after grounding resolution +#[derive(Debug, Clone)] +pub struct RequestConfig { + /// The request type: "agent", "web_search", or "image_gen" + pub request_type: String, + /// Whether to inject the googleSearch tool + pub inject_google_search: bool, + /// The final model name (with suffixes stripped) + pub final_model: String, + /// Image generation configuration (if request_type is image_gen) + pub image_config: Option, +} + +pub fn resolve_request_config( + original_model: &str, + mapped_model: &str, + tools: &Option> +) -> RequestConfig { + // 1. Image Generation Check (Priority) + if mapped_model.starts_with("gemini-3-pro-image") { + let (image_config, parsed_base_model) = parse_image_config(original_model); + + return RequestConfig { + request_type: "image_gen".to_string(), + inject_google_search: false, + final_model: parsed_base_model, + image_config: Some(image_config), + }; + } + + // 检测是否有联网工具定义 (内置功能调用) + let has_networking_tool = detects_networking_tool(tools); + // 检测是否包含非联网工具 (如 MCP 本地工具) + let has_non_networking = contains_non_networking_tool(tools); + + // Strip -online suffix from original model if present (to detect networking intent) + let is_online_suffix = original_model.ends_with("-online"); + + // High-quality grounding allowlist (Only for models known to support search and be relatively 'safe') + let is_high_quality_model = mapped_model == "gemini-2.5-flash" + || mapped_model == "gemini-1.5-pro" + || mapped_model.starts_with("gemini-1.5-pro-") + || mapped_model.starts_with("gemini-2.5-flash-") + || mapped_model.starts_with("gemini-2.0-flash") + || mapped_model.starts_with("gemini-3-") + || mapped_model.contains("claude-3-5-sonnet") + || mapped_model.contains("claude-3-opus") + || mapped_model.contains("claude-sonnet") + || mapped_model.contains("claude-opus") + || mapped_model.contains("claude-4"); + + // Determine if we should enable networking + // [FIX] 禁用基于模型的自动联网逻辑,防止图像请求被联网搜索结果覆盖。 + // 仅在用户显式请求联网时启用:1) -online 后缀 2) 携带联网工具定义 + let enable_networking = is_online_suffix || has_networking_tool; + + // The final model to send upstream should be the MAPPED model, + // but if searching, we MUST ensure the model name is one the backend associates with search. + // Based on ref_Antigravity2Api practice, we force a stable search model for search requests. + let mut final_model = mapped_model.trim_end_matches("-online").to_string(); + if enable_networking { + // If it's a thinking model (which doesn't support tools) or a Claude-style alias, + // fallback to gemini-2.5-flash which is the standard workhorse for search. + if final_model.contains("thinking") || !final_model.starts_with("gemini-") { + final_model = "gemini-2.5-flash".to_string(); + } + } + + RequestConfig { + request_type: if enable_networking { + "web_search".to_string() + } else { + "agent".to_string() + }, + inject_google_search: enable_networking, + final_model, + image_config: None, + } +} + +/// Parse image configuration from model name suffixes +/// Returns (image_config, clean_model_name) +fn parse_image_config(model_name: &str) -> (Value, String) { + let mut aspect_ratio = "1:1"; + let _image_size = "1024x1024"; // Default, not explicitly sent unless 4k/hd + + if model_name.contains("-16x9") { aspect_ratio = "16:9"; } + else if model_name.contains("-9x16") { aspect_ratio = "9:16"; } + else if model_name.contains("-4x3") { aspect_ratio = "4:3"; } + else if model_name.contains("-3x4") { aspect_ratio = "3:4"; } + else if model_name.contains("-1x1") { aspect_ratio = "1:1"; } + + let is_hd = model_name.contains("-4k") || model_name.contains("-hd"); + + let mut config = serde_json::Map::new(); + config.insert("aspectRatio".to_string(), json!(aspect_ratio)); + + if is_hd { + config.insert("imageSize".to_string(), json!("4K")); + } + + // The upstream model must be EXACTLY "gemini-3-pro-image" + (serde_json::Value::Object(config), "gemini-3-pro-image".to_string()) +} + +/// Inject current googleSearch tool and ensure no duplicate legacy search tools +pub fn inject_google_search_tool(body: &mut Value) { + if let Some(obj) = body.as_object_mut() { + let tools_entry = obj.entry("tools").or_insert_with(|| json!([])); + if let Some(tools_arr) = tools_entry.as_array_mut() { + // [安全校验] 如果数组中已经包含 functionDeclarations,严禁注入 googleSearch + // 因为 Gemini v1internal 不支持在一次请求中混用 search 和 functions + let has_functions = tools_arr.iter().any(|t| { + t.as_object().map_or(false, |o| o.contains_key("functionDeclarations")) + }); + + if has_functions { + tracing::info!("Skipping googleSearch injection due to existing functionDeclarations"); + return; + } + + // 首先清理掉已存在的 googleSearch 或 googleSearchRetrieval,以防重复产生冲突 + tools_arr.retain(|t| { + if let Some(o) = t.as_object() { + !(o.contains_key("googleSearch") || o.contains_key("googleSearchRetrieval")) + } else { + true + } + }); + + // 注入统一的 googleSearch (v1internal 规范) + tools_arr.push(json!({ + "googleSearch": {} + })); + } + } +} + +/// 深度迭代清理客户端发送的 [undefined] 脏字符串,防止 Gemini 接口校验失败 +pub fn deep_clean_undefined(value: &mut Value) { + match value { + Value::Object(map) => { + // 移除值为 "[undefined]" 的键 + map.retain(|_, v| { + if let Some(s) = v.as_str() { + s != "[undefined]" + } else { + true + } + }); + // 递归处理嵌套 + for v in map.values_mut() { + deep_clean_undefined(v); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + deep_clean_undefined(v); + } + } + _ => {} + } +} + +/// Detects if the tool list contains a request for networking/web search. +/// Supported keywords: "web_search", "google_search", "web_search_20250305" +pub fn detects_networking_tool(tools: &Option>) -> bool { + if let Some(list) = tools { + for tool in list { + // 1. 直发风格 (Claude/Simple OpenAI/Anthropic Builtin/Vertex): { "name": "..." } 或 { "type": "..." } + if let Some(n) = tool.get("name").and_then(|v| v.as_str()) { + if n == "web_search" || n == "google_search" || n == "web_search_20250305" || n == "google_search_retrieval" { + return true; + } + } + + if let Some(t) = tool.get("type").and_then(|v| v.as_str()) { + if t == "web_search_20250305" || t == "google_search" || t == "web_search" || t == "google_search_retrieval" { + return true; + } + } + + // 2. OpenAI 嵌套风格: { "type": "function", "function": { "name": "..." } } + if let Some(func) = tool.get("function") { + if let Some(n) = func.get("name").and_then(|v| v.as_str()) { + let keywords = ["web_search", "google_search", "web_search_20250305", "google_search_retrieval"]; + if keywords.contains(&n) { + return true; + } + } + } + + // 3. Gemini 原生风格: { "functionDeclarations": [ { "name": "..." } ] } + if let Some(decls) = tool.get("functionDeclarations").and_then(|v| v.as_array()) { + for decl in decls { + if let Some(n) = decl.get("name").and_then(|v| v.as_str()) { + if n == "web_search" || n == "google_search" || n == "google_search_retrieval" { + return true; + } + } + } + } + + // 4. Gemini googleSearch 声明 (含 googleSearchRetrieval 变体) + if tool.get("googleSearch").is_some() || tool.get("googleSearchRetrieval").is_some() { + return true; + } + } + } + false +} + +/// 探测是否包含非联网相关的本地函数工具 +pub fn contains_non_networking_tool(tools: &Option>) -> bool { + if let Some(list) = tools { + for tool in list { + let mut is_networking = false; + + // 简单逻辑:如果它是一个函数声明且名字不是联网关键词,则视为非联网工具 + if let Some(n) = tool.get("name").and_then(|v| v.as_str()) { + let keywords = ["web_search", "google_search", "web_search_20250305", "google_search_retrieval"]; + if keywords.contains(&n) { is_networking = true; } + } else if let Some(func) = tool.get("function") { + if let Some(n) = func.get("name").and_then(|v| v.as_str()) { + let keywords = ["web_search", "google_search", "web_search_20250305", "google_search_retrieval"]; + if keywords.contains(&n) { is_networking = true; } + } + } else if tool.get("googleSearch").is_some() || tool.get("googleSearchRetrieval").is_some() { + is_networking = true; + } else if tool.get("functionDeclarations").is_some() { + // 如果是 Gemini 风格的 functionDeclarations,进去看一眼 + if let Some(decls) = tool.get("functionDeclarations").and_then(|v| v.as_array()) { + for decl in decls { + if let Some(n) = decl.get("name").and_then(|v| v.as_str()) { + let keywords = ["web_search", "google_search", "google_search_retrieval"]; + if !keywords.contains(&n) { + return true; // 发现本地函数 + } + } + } + } + is_networking = true; // 即使全是联网,外层也标记为联网 + } + + if !is_networking { + return true; + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_high_quality_model_auto_grounding() { + let config = resolve_request_config("gpt-4o", "gemini-2.5-flash", &None); + assert_eq!(config.request_type, "web_search"); + assert!(config.inject_google_search); + assert_eq!(config.final_model, "gemini-2.5-flash"); // 修正断言: final_model = mapped_model + } + + #[test] + fn test_gemini_native_tool_detection() { + let tools = Some(vec![json!({ + "functionDeclarations": [ + { "name": "web_search", "parameters": {} } + ] + })]); + assert!(detects_networking_tool(&tools)); + } + + #[test] + fn test_online_suffix_force_grounding() { + let config = resolve_request_config("gemini-3-flash-online", "gemini-3-flash", &None); + assert_eq!(config.request_type, "web_search"); + assert!(config.inject_google_search); + assert_eq!(config.final_model, "gemini-3-flash"); + } + + #[test] + fn test_default_no_grounding() { + let config = resolve_request_config("claude-sonnet", "gemini-3-flash", &None); + assert_eq!(config.request_type, "agent"); + assert!(!config.inject_google_search); + } + + #[test] + fn test_image_model_excluded() { + let config = resolve_request_config("gemini-3-pro-image", "gemini-3-pro-image", &None); + assert_eq!(config.request_type, "image_gen"); + assert!(!config.inject_google_search); + } +} diff --git a/server/src/proxy/mappers/gemini/mod.rs b/server/src/proxy/mappers/gemini/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..d89e4018ec2f2ab9006a49b1d0352463984c0621 --- /dev/null +++ b/server/src/proxy/mappers/gemini/mod.rs @@ -0,0 +1,8 @@ +// Gemini mapper 模块 +// 负责 v1internal 包装/解包 + +pub mod models; +pub mod wrapper; + +// No public exports needed here if unused +pub use wrapper::*; diff --git a/server/src/proxy/mappers/gemini/models.rs b/server/src/proxy/mappers/gemini/models.rs new file mode 100644 index 0000000000000000000000000000000000000000..e8446c3c68159856935f45ee2c5d0dfe66d937d3 --- /dev/null +++ b/server/src/proxy/mappers/gemini/models.rs @@ -0,0 +1,16 @@ +// Gemini v1internal 数据模型 +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct V1InternalRequest { + pub project: String, + #[serde(rename = "requestId")] + pub request_id: String, + pub request: serde_json::Value, + pub model: String, + #[serde(rename = "userAgent")] + pub user_agent: String, + #[serde(rename = "requestType")] + pub request_type: String, +} diff --git a/server/src/proxy/mappers/gemini/wrapper.rs b/server/src/proxy/mappers/gemini/wrapper.rs new file mode 100644 index 0000000000000000000000000000000000000000..40dc190218527d40ffbc44d101cfe6419fdc58dd --- /dev/null +++ b/server/src/proxy/mappers/gemini/wrapper.rs @@ -0,0 +1,140 @@ +// Gemini v1internal 包装/解包 +use serde_json::{json, Value}; + +/// 包装请求体为 v1internal 格式 +pub fn wrap_request(body: &Value, project_id: &str, mapped_model: &str) -> Value { + // 优先使用传入的 mapped_model,其次尝试从 body 获取 + let original_model = body.get("model").and_then(|v| v.as_str()).unwrap_or(mapped_model); + + // 如果 mapped_model 是空的,则使用 original_model + let final_model_name = if !mapped_model.is_empty() { + mapped_model + } else { + original_model + }; + + // 复制 body 以便修改 + let mut inner_request = body.clone(); + + // 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入) + crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request); + + // 强制设置 Gemini v1internal 的最大输出 token 数 + if let Some(obj) = inner_request.as_object_mut() { + let gen_config = obj.entry("generationConfig").or_insert_with(|| json!({})); + if let Some(gen_obj) = gen_config.as_object_mut() { + gen_obj.insert("maxOutputTokens".to_string(), json!(64000)); // Sync with others + } + } + + // 提取 tools 列表以进行联网探测 (Gemini 风格可能是嵌套的) + let tools_val: Option> = inner_request.get("tools").and_then(|t| t.as_array()).map(|arr| { + arr.clone() + }); + + // Use shared grounding/config logic + let config = crate::proxy::mappers::common_utils::resolve_request_config(original_model, final_model_name, &tools_val); + + // Clean tool declarations (remove forbidden Schema fields like multipleOf, and remove redundant search decls) + if let Some(tools) = inner_request.get_mut("tools") { + if let Some(tools_arr) = tools.as_array_mut() { + for tool in tools_arr { + if let Some(decls) = tool.get_mut("functionDeclarations") { + if let Some(decls_arr) = decls.as_array_mut() { + // 1. 过滤掉联网关键字函数 + decls_arr.retain(|decl| { + if let Some(name) = decl.get("name").and_then(|v| v.as_str()) { + if name == "web_search" || name == "google_search" { + return false; + } + } + true + }); + + // 2. 清洗剩余 Schema + for decl in decls_arr { + if let Some(params) = decl.get_mut("parameters") { + crate::proxy::common::json_schema::clean_json_schema(params); + } + } + } + } + } + } + } + + tracing::info!("[Debug] Gemini Wrap: original='{}', mapped='{}', final='{}', type='{}'", + original_model, final_model_name, config.final_model, config.request_type); + + // Inject googleSearch tool if needed + if config.inject_google_search { + crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request); + } + + // Inject imageConfig if present (for image generation models) + if let Some(image_config) = config.image_config { + if let Some(obj) = inner_request.as_object_mut() { + // 1. Remove tools (image generation does not support tools) + obj.remove("tools"); + + // 2. Remove systemInstruction (image generation does not support system prompts) + obj.remove("systemInstruction"); + + // 3. Clean generationConfig (remove thinkingConfig, responseMimeType, responseModalities etc.) + let gen_config = obj.entry("generationConfig").or_insert_with(|| json!({})); + if let Some(gen_obj) = gen_config.as_object_mut() { + gen_obj.remove("thinkingConfig"); + gen_obj.remove("responseMimeType"); + gen_obj.remove("responseModalities"); // Cherry Studio sends this, might conflict + gen_obj.insert("imageConfig".to_string(), image_config); + } + } + } + + let final_request = json!({ + "project": project_id, + "requestId": format!("agent-{}", uuid::Uuid::new_v4()), // 修正为 agent- 前缀 + "request": inner_request, + "model": config.final_model, + "userAgent": "antigravity", + "requestType": config.request_type + }); + + final_request +} + +/// 解包响应(提取 response 字段) +pub fn unwrap_response(response: &Value) -> Value { + response.get("response").unwrap_or(response).clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wrap_request() { + let body = json!({ + "model": "gemini-2.5-flash", + "contents": [{"role": "user", "parts": [{"text": "Hi"}]}] + }); + + let result = wrap_request(&body, "test-project", "gemini-2.5-flash"); + assert_eq!(result["project"], "test-project"); + assert_eq!(result["model"], "gemini-2.5-flash"); + assert!(result["requestId"].as_str().unwrap().starts_with("agent-")); + } + + #[test] + fn test_unwrap_response() { + let wrapped = json!({ + "response": { + "candidates": [{"content": {"parts": [{"text": "Hello"}]}}] + } + }); + + let result = unwrap_response(&wrapped); + assert!(result.get("candidates").is_some()); + assert!(result.get("response").is_none()); + } +} diff --git a/server/src/proxy/mappers/mod.rs b/server/src/proxy/mappers/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6869fc7bf081246e908aba83fa741777029a857 --- /dev/null +++ b/server/src/proxy/mappers/mod.rs @@ -0,0 +1,8 @@ +// Mappers 模块 - 协议转换器 +// 协议转换器模块 + +pub mod claude; +pub mod common_utils; +pub mod gemini; +pub mod openai; +pub mod signature_store; diff --git a/server/src/proxy/mappers/openai/mod.rs b/server/src/proxy/mappers/openai/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..92039fb9b1ff2e6558341b0a929f4584b2a28ae7 --- /dev/null +++ b/server/src/proxy/mappers/openai/mod.rs @@ -0,0 +1,12 @@ +// OpenAI mapper 模块 +// 负责 OpenAI ↔ Gemini 协议转换 + +pub mod models; +pub mod request; +pub mod response; +pub mod streaming; + +pub use models::*; +pub use request::*; +pub use response::*; +// No public exports needed here if unused diff --git a/server/src/proxy/mappers/openai/models.rs b/server/src/proxy/mappers/openai/models.rs new file mode 100644 index 0000000000000000000000000000000000000000..a22d1d77999c79082a2362303ef2e7f6285e45c8 --- /dev/null +++ b/server/src/proxy/mappers/openai/models.rs @@ -0,0 +1,105 @@ +// OpenAI 数据模型 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIRequest { + pub model: String, + #[serde(default)] + pub messages: Vec, + #[serde(default)] + pub prompt: Option, + #[serde(default)] + pub stream: bool, + #[serde(rename = "max_tokens")] + pub max_tokens: Option, + pub temperature: Option, + #[serde(rename = "top_p")] + pub top_p: Option, + pub stop: Option, + pub response_format: Option, + #[serde(default)] + pub tools: Option>, + #[serde(rename = "tool_choice")] + pub tool_choice: Option, + #[serde(rename = "parallel_tool_calls")] + pub parallel_tool_calls: Option, + // Codex proprietary fields + pub instructions: Option, + pub input: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseFormat { + pub r#type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum OpenAIContent { + String(String), + Array(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum OpenAIContentBlock { + #[serde(rename = "text")] + Text { + text: String, + }, + #[serde(rename = "image_url")] + ImageUrl { + image_url: OpenAIImageUrl, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpenAIImageUrl { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIMessage { + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + pub r#type: String, + pub function: ToolFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFunction { + pub name: String, + pub arguments: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenAIResponse { + pub id: String, + pub object: String, + pub created: u64, + pub model: String, + pub choices: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Choice { + pub index: u32, + pub message: OpenAIMessage, + pub finish_reason: Option, +} diff --git a/server/src/proxy/mappers/openai/request.rs b/server/src/proxy/mappers/openai/request.rs new file mode 100644 index 0000000000000000000000000000000000000000..6276e1cfb40914d558280c6d178690c299147795 --- /dev/null +++ b/server/src/proxy/mappers/openai/request.rs @@ -0,0 +1,417 @@ +// OpenAI → Gemini 请求转换 +use super::models::*; +use serde_json::{json, Value}; +use super::streaming::get_thought_signature; + +pub fn transform_openai_request(request: &OpenAIRequest, project_id: &str, mapped_model: &str) -> Value { + // 将 OpenAI 工具转为 Value 数组以便探测 + let tools_val = request.tools.as_ref().map(|list| { + list.iter().map(|v| v.clone()).collect::>() + }); + + // Resolve grounding config + let config = crate::proxy::mappers::common_utils::resolve_request_config(&request.model, mapped_model, &tools_val); + + tracing::info!("[Debug] OpenAI Request: original='{}', mapped='{}', type='{}', has_image_config={}", + request.model, mapped_model, config.request_type, config.image_config.is_some()); + + // 1. 提取所有 System Message 并注入补丁 + let mut system_instructions: Vec = request.messages.iter() + .filter(|msg| msg.role == "system") + .filter_map(|msg| { + msg.content.as_ref().map(|c| match c { + OpenAIContent::String(s) => s.clone(), + OpenAIContent::Array(blocks) => { + blocks.iter().filter_map(|b| { + if let OpenAIContentBlock::Text { text } = b { + Some(text.clone()) + } else { + None + } + }).collect::>().join("\n") + } + }) + }) + .collect(); + + // 注入 Codex/Coding Agent 补丁 + system_instructions.push("You are a coding agent. You MUST use the provided 'shell' tool to perform ANY filesystem operations (reading, writing, creating files). Do not output JSON code blocks for tool execution; invoke the functions directly. To create a file, use the 'shell' tool with 'New-Item' or 'Set-Content' (Powershell). NEVER simulate/hallucinate actions in text without calling the tool first.".to_string()); + + // Pre-scan to map tool_call_id to function name (for Codex) + let mut tool_id_to_name = std::collections::HashMap::new(); + for msg in &request.messages { + if let Some(tool_calls) = &msg.tool_calls { + for call in tool_calls { + let name = &call.function.name; + let final_name = if name == "local_shell_call" { "shell" } else { name }; + tool_id_to_name.insert(call.id.clone(), final_name.to_string()); + } + } + } + + // 从全局存储获取 thoughtSignature (PR #93 支持) + let global_thought_sig = get_thought_signature(); + if global_thought_sig.is_some() { + tracing::info!("从全局存储获取到 thoughtSignature (长度: {})", global_thought_sig.as_ref().unwrap().len()); + } + + // 2. 构建 Gemini contents (过滤掉 system) + let contents: Vec = request + .messages + .iter() + .filter(|msg| msg.role != "system") + .map(|msg| { + let role = match msg.role.as_str() { + "assistant" => "model", + "tool" | "function" => "user", + _ => &msg.role, + }; + + let mut parts = Vec::new(); + + // Handle content (multimodal or text) + if let Some(content) = &msg.content { + match content { + OpenAIContent::String(s) => { + if !s.is_empty() { + if role == "user" && mapped_model.contains("gemini-3") { + // 为 Gemini 3 用户消息添加提醒补丁 + let reminder = "\n\n(SYSTEM REMINDER: You MUST use the 'shell' tool to perform this action. Do not simply state it is done.)"; + parts.push(json!({"text": format!("{}{}", s, reminder)})); + } else { + parts.push(json!({"text": s})); + } + } + } + OpenAIContent::Array(blocks) => { + for block in blocks { + match block { + OpenAIContentBlock::Text { text } => { + if role == "user" && mapped_model.contains("gemini-3") { + let reminder = "\n\n(SYSTEM REMINDER: You MUST use the 'shell' tool to perform this action. Do not simply state it is done.)"; + parts.push(json!({ "text": format!("{}{}", text, reminder) })); + } else { + parts.push(json!({"text": text})); + } + } + OpenAIContentBlock::ImageUrl { image_url } => { + if image_url.url.starts_with("data:") { + if let Some(pos) = image_url.url.find(",") { + let mime_part = &image_url.url[5..pos]; + let mime_type = mime_part.split(';').next().unwrap_or("image/jpeg"); + let data = &image_url.url[pos + 1..]; + + parts.push(json!({ + "inlineData": { "mimeType": mime_type, "data": data } + })); + } + } else if image_url.url.starts_with("http") { + parts.push(json!({ + "fileData": { "fileUri": &image_url.url, "mimeType": "image/jpeg" } + })); + } else { + // [NEW] 处理本地文件路径 (file:// 或 Windows/Unix 路径) + let file_path = if image_url.url.starts_with("file://") { + // 移除 file:// 前缀 + #[cfg(target_os = "windows")] + { image_url.url.trim_start_matches("file:///").replace('/', "\\") } + #[cfg(not(target_os = "windows"))] + { image_url.url.trim_start_matches("file://").to_string() } + } else { + image_url.url.clone() + }; + + tracing::info!("[OpenAI-Request] Reading local image: {}", file_path); + + // 读取文件并转换为 base64 + if let Ok(file_bytes) = std::fs::read(&file_path) { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(&file_bytes); + + // 根据文件扩展名推断 MIME 类型 + let mime_type = if file_path.to_lowercase().ends_with(".png") { + "image/png" + } else if file_path.to_lowercase().ends_with(".gif") { + "image/gif" + } else if file_path.to_lowercase().ends_with(".webp") { + "image/webp" + } else { + "image/jpeg" + }; + + parts.push(json!({ + "inlineData": { "mimeType": mime_type, "data": b64 } + })); + tracing::info!("[OpenAI-Request] Successfully loaded image: {} ({} bytes)", file_path, file_bytes.len()); + } else { + tracing::warn!("[OpenAI-Request] Failed to read local image: {}", file_path); + } + } + } + } + } + } + } + } + + // Handle tool calls (assistant message) + if let Some(tool_calls) = &msg.tool_calls { + for (index, tc) in tool_calls.iter().enumerate() { + /* 暂时移除:防止 Codex CLI 界面碎片化 + if index == 0 && parts.is_empty() { + if mapped_model.contains("gemini-3") { + parts.push(json!({"text": "Thinking Process: Determining necessary tool actions."})); + } + } + */ + + let args = serde_json::from_str::(&tc.function.arguments).unwrap_or(json!({})); + let mut func_call_part = json!({ + "functionCall": { + "name": if tc.function.name == "local_shell_call" { "shell" } else { &tc.function.name }, + "args": args + } + }); + + // [修复] 为该消息内的所有工具调用注入 thoughtSignature (PR #114 优化) + if let Some(ref sig) = global_thought_sig { + func_call_part["thoughtSignature"] = json!(sig); + } + + parts.push(func_call_part); + } + } + + // Handle tool response + if msg.role == "tool" || msg.role == "function" { + let name = msg.name.as_deref().unwrap_or("unknown"); + let final_name = if name == "local_shell_call" { "shell" } + else if let Some(id) = &msg.tool_call_id { tool_id_to_name.get(id).map(|s| s.as_str()).unwrap_or(name) } + else { name }; + + let content_val = match &msg.content { + Some(OpenAIContent::String(s)) => s.clone(), + Some(OpenAIContent::Array(blocks)) => blocks.iter().filter_map(|b| if let OpenAIContentBlock::Text { text } = b { Some(text.clone()) } else { None }).collect::>().join("\n"), + None => "".to_string() + }; + + parts.push(json!({ + "functionResponse": { + "name": final_name, + "response": { "result": content_val } + } + })); + } + + json!({ "role": role, "parts": parts }) + }) + .collect(); + + // [PR #合并] 合并连续相同角色的消息 (Gemini 强制要求 user/model 交替) + let mut merged_contents: Vec = Vec::new(); + for msg in contents { + if let Some(last) = merged_contents.last_mut() { + if last["role"] == msg["role"] { + // 合并 parts + if let (Some(last_parts), Some(msg_parts)) = (last["parts"].as_array_mut(), msg["parts"].as_array()) { + last_parts.extend(msg_parts.iter().cloned()); + continue; + } + } + } + merged_contents.push(msg); + } + let contents = merged_contents; + + // 3. 构建请求体 + let mut gen_config = json!({ + "maxOutputTokens": request.max_tokens.unwrap_or(64000), + "temperature": request.temperature.unwrap_or(1.0), + "topP": request.top_p.unwrap_or(1.0), + }); + + if let Some(stop) = &request.stop { + if stop.is_string() { gen_config["stopSequences"] = json!([stop]); } + else if stop.is_array() { gen_config["stopSequences"] = stop.clone(); } + } + + if let Some(fmt) = &request.response_format { + if fmt.r#type == "json_object" { + gen_config["responseMimeType"] = json!("application/json"); + } + } + + let mut inner_request = json!({ + "contents": contents, + "generationConfig": gen_config, + "safetySettings": [ + { "category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF" }, + { "category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF" }, + ] + }); + + // 深度清理 [undefined] 字符串 (Cherry Studio 等客户端常见注入) + crate::proxy::mappers::common_utils::deep_clean_undefined(&mut inner_request); + + // 4. Handle Tools (Merged Cleaning) + if let Some(tools) = &request.tools { + let mut function_declarations: Vec = Vec::new(); + for tool in tools.iter() { + let mut gemini_func = if let Some(func) = tool.get("function") { + func.clone() + } else { + let mut func = tool.clone(); + if let Some(obj) = func.as_object_mut() { + obj.remove("type"); + obj.remove("strict"); + obj.remove("additionalProperties"); + } + func + }; + + if let Some(name) = gemini_func.get("name").and_then(|v| v.as_str()) { + // 跳过内置联网工具名称,避免重复定义 + if name == "web_search" || name == "google_search" || name == "web_search_20250305" { + continue; + } + + if name == "local_shell_call" { + if let Some(obj) = gemini_func.as_object_mut() { + obj.insert("name".to_string(), json!("shell")); + } + } + } + + // [NEW CRITICAL FIX] 清除函数定义根层级的非法字段 (解决报错持久化) + if let Some(obj) = gemini_func.as_object_mut() { + obj.remove("format"); + obj.remove("strict"); + obj.remove("additionalProperties"); + obj.remove("type"); // [NEW] Gemini 不支持在 FunctionDeclaration 根层级出现 type: "function" + } + + if let Some(params) = gemini_func.get_mut("parameters") { + // [DEEP FIX] 统一调用公共库清洗:展开 $ref 并剔除所有层级的 format/definitions + crate::proxy::common::json_schema::clean_json_schema(params); + + // Gemini v1internal 要求: + // 1. type 必须是大写 (OBJECT, STRING 等) + // 2. 根对象必须有 "type": "OBJECT" + if let Some(params_obj) = params.as_object_mut() { + if !params_obj.contains_key("type") { + params_obj.insert("type".to_string(), json!("OBJECT")); + } + } + + // 递归转换 type 为大写 (符合 Protobuf 定义) + enforce_uppercase_types(params); + } + function_declarations.push(gemini_func); + } + + if !function_declarations.is_empty() { + inner_request["tools"] = json!([{ "functionDeclarations": function_declarations }]); + } + } + + if !system_instructions.is_empty() { + inner_request["systemInstruction"] = json!({ "parts": [{"text": system_instructions.join("\n\n")}] }); + } + + if config.inject_google_search { + crate::proxy::mappers::common_utils::inject_google_search_tool(&mut inner_request); + } + + if let Some(image_config) = config.image_config { + if let Some(obj) = inner_request.as_object_mut() { + obj.remove("tools"); + obj.remove("systemInstruction"); + let gen_config = obj.entry("generationConfig").or_insert_with(|| json!({})); + if let Some(gen_obj) = gen_config.as_object_mut() { + gen_obj.remove("thinkingConfig"); + gen_obj.remove("responseMimeType"); + gen_obj.remove("responseModalities"); + gen_obj.insert("imageConfig".to_string(), image_config); + } + } + } + + json!({ + "project": project_id, + "requestId": format!("openai-{}", uuid::Uuid::new_v4()), + "request": inner_request, + "model": config.final_model, + "userAgent": "antigravity", + "requestType": config.request_type + }) +} + +fn enforce_uppercase_types(value: &mut Value) { + if let Value::Object(map) = value { + if let Some(type_val) = map.get_mut("type") { + if let Value::String(ref mut s) = type_val { + *s = s.to_uppercase(); + } + } + if let Some(properties) = map.get_mut("properties") { + if let Value::Object(ref mut props) = properties { + for v in props.values_mut() { + enforce_uppercase_types(v); + } + } + } + if let Some(items) = map.get_mut("items") { + enforce_uppercase_types(items); + } + } else if let Value::Array(arr) = value { + for item in arr { + enforce_uppercase_types(item); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transform_openai_request_multimodal() { + let req = OpenAIRequest { + model: "gpt-4-vision".to_string(), + messages: vec![OpenAIMessage { + role: "user".to_string(), + content: Some(OpenAIContent::Array(vec![ + OpenAIContentBlock::Text { text: "What is in this image?".to_string() }, + OpenAIContentBlock::ImageUrl { image_url: OpenAIImageUrl { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==".to_string(), + detail: None + } } + ])), + tool_calls: None, + tool_call_id: None, + name: None, + }], + stream: false, + max_tokens: None, + temperature: None, + top_p: None, + stop: None, + response_format: None, + tools: None, + tool_choice: None, + parallel_tool_calls: None, + instructions: None, + input: None, + prompt: None, + }; + + let result = transform_openai_request(&req, "test-v", "gemini-1.5-flash"); + let parts = &result["request"]["contents"][0]["parts"]; + assert_eq!(parts.as_array().unwrap().len(), 2); + assert_eq!(parts[0]["text"].as_str().unwrap(), "What is in this image?"); + assert_eq!(parts[1]["inlineData"]["mimeType"].as_str().unwrap(), "image/png"); + } +} diff --git a/server/src/proxy/mappers/openai/response.rs b/server/src/proxy/mappers/openai/response.rs new file mode 100644 index 0000000000000000000000000000000000000000..581781b55c5e2fb0a5d0b3bebcae7532521966d7 --- /dev/null +++ b/server/src/proxy/mappers/openai/response.rs @@ -0,0 +1,163 @@ +use super::models::*; +use serde_json::Value; + +pub fn transform_openai_response(gemini_response: &Value) -> OpenAIResponse { + // 解包 response 字段 + let raw = gemini_response.get("response").unwrap_or(gemini_response); + + // 提取 content 和 tool_calls + let mut content_out = String::new(); + let mut tool_calls = Vec::new(); + + if let Some(parts) = raw.get("candidates") + .and_then(|c| c.get(0)) + .and_then(|cand| cand.get("content")) + .and_then(|content| content.get("parts")) + .and_then(|p| p.as_array()) { + + for part in parts { + /* 暂时禁用:思维链/推理部分 (Gemini 2.0+) 避免干扰 Codex CLI 等非推理客户端 + if let Some(thought) = part.get("thought").and_then(|t| t.as_str()) { + if !thought.is_empty() { + content_out.push_str("\n"); + content_out.push_str(thought); + content_out.push_str("\n\n\n"); + } + } + */ + + // 文本部分 + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + content_out.push_str(text); + } + + // 工具调用部分 + if let Some(fc) = part.get("functionCall") { + let name = fc.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); + let args = fc.get("args").map(|v| v.to_string()).unwrap_or_else(|| "{}".to_string()); + let id = fc.get("id").and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("{}-{}", name, uuid::Uuid::new_v4())); + + tool_calls.push(ToolCall { + id, + r#type: "function".to_string(), + function: ToolFunction { + name: name.to_string(), + arguments: args, + }, + }); + } + + // 图片处理 + if let Some(img) = part.get("inlineData") { + let mime_type = img.get("mimeType").and_then(|v| v.as_str()).unwrap_or("image/png"); + let data = img.get("data").and_then(|v| v.as_str()).unwrap_or(""); + if !data.is_empty() { + content_out.push_str(&format!("![image](data:{};base64,{})", mime_type, data)); + } + } + } + } + + // 提取并处理联网搜索引文 (Grounding Metadata) + if let Some(grounding) = raw.get("candidates") + .and_then(|c| c.get(0)) + .and_then(|cand| cand.get("groundingMetadata")) { + + let mut grounding_text = String::new(); + + // 1. 处理搜索词 + if let Some(queries) = grounding.get("webSearchQueries").and_then(|q| q.as_array()) { + let query_list: Vec<&str> = queries.iter().filter_map(|v| v.as_str()).collect(); + if !query_list.is_empty() { + grounding_text.push_str("\n\n---\n**🔍 已为您搜索:** "); + grounding_text.push_str(&query_list.join(", ")); + } + } + + // 2. 处理来源链接 (Chunks) + if let Some(chunks) = grounding.get("groundingChunks").and_then(|c| c.as_array()) { + let mut links = Vec::new(); + for (i, chunk) in chunks.iter().enumerate() { + if let Some(web) = chunk.get("web") { + let title = web.get("title").and_then(|v| v.as_str()).unwrap_or("网页来源"); + let uri = web.get("uri").and_then(|v| v.as_str()).unwrap_or("#"); + links.push(format!("[{}] [{}]({})", i + 1, title, uri)); + } + } + + if !links.is_empty() { + grounding_text.push_str("\n\n**🌐 来源引文:**\n"); + grounding_text.push_str(&links.join("\n")); + } + } + + if !grounding_text.is_empty() { + content_out.push_str(&grounding_text); + } + } + + // 提取 finish_reason + let finish_reason = raw + .get("candidates") + .and_then(|c| c.get(0)) + .and_then(|cand| cand.get("finishReason")) + .and_then(|f| f.as_str()) + .map(|f| match f { + "STOP" => "stop", + "MAX_TOKENS" => "length", + "SAFETY" => "content_filter", + "RECITATION" => "content_filter", + _ => "stop", + }) + .unwrap_or("stop"); + + OpenAIResponse { + id: raw.get("responseId").and_then(|v| v.as_str()).unwrap_or("resp_unknown").to_string(), + object: "chat.completion".to_string(), + created: chrono::Utc::now().timestamp() as u64, + model: raw.get("modelVersion").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(), + choices: vec![Choice { + index: 0, + message: OpenAIMessage { + role: "assistant".to_string(), + content: if content_out.is_empty() { None } else { Some(OpenAIContent::String(content_out)) }, + tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls) }, + tool_call_id: None, + name: None, + }, + finish_reason: Some(finish_reason.to_string()), + }], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_transform_openai_response() { + let gemini_resp = json!({ + "candidates": [{ + "content": { + "parts": [{"text": "Hello!"}] + }, + "finishReason": "STOP" + }], + "modelVersion": "gemini-2.5-pro", + "responseId": "resp_123" + }); + + let result = transform_openai_response(&gemini_resp); + assert_eq!(result.object, "chat.completion"); + + let content = match result.choices[0].message.content.as_ref().unwrap() { + OpenAIContent::String(s) => s, + _ => panic!("Expected string content"), + }; + assert_eq!(content, "Hello!"); + assert_eq!(result.choices[0].finish_reason, Some("stop".to_string())); + } +} diff --git a/server/src/proxy/mappers/openai/streaming.rs b/server/src/proxy/mappers/openai/streaming.rs new file mode 100644 index 0000000000000000000000000000000000000000..25f7d0485e942b9e5a1ef1d338b10b32bc7a3905 --- /dev/null +++ b/server/src/proxy/mappers/openai/streaming.rs @@ -0,0 +1,785 @@ +// OpenAI 流式转换 +use bytes::{Bytes, BytesMut}; +use futures::{Stream, StreamExt}; +use serde_json::{json, Value}; +use std::pin::Pin; +use std::sync::{Mutex, OnceLock}; +use chrono::Utc; +use uuid::Uuid; +use tracing::debug; +use rand::Rng; + +// === 全局 ThoughtSignature 存储 === +// 用于在流式响应和后续请求之间传递签名,避免嵌入到用户可见的文本中 +static GLOBAL_THOUGHT_SIG: OnceLock>> = OnceLock::new(); + +fn get_thought_sig_storage() -> &'static Mutex> { + GLOBAL_THOUGHT_SIG.get_or_init(|| Mutex::new(None)) +} + +/// 保存 thoughtSignature 到全局存储 +/// 注意:只在新签名比现有签名更长时才存储,避免短签名覆盖有效签名 +pub fn store_thought_signature(sig: &str) { + if let Ok(mut guard) = get_thought_sig_storage().lock() { + let should_store = match &*guard { + None => true, // 没有签名,直接存储 + Some(existing) => sig.len() > existing.len(), // 只有新签名更长才存储 + }; + + if should_store { + tracing::info!("[ThoughtSig] 存储新签名 (长度: {},替换旧长度: {:?})", + sig.len(), + guard.as_ref().map(|s| s.len()) + ); + *guard = Some(sig.to_string()); + } else { + tracing::debug!("[ThoughtSig] 跳过短签名 (新长度: {},现有长度: {})", + sig.len(), + guard.as_ref().map(|s| s.len()).unwrap_or(0) + ); + } + } +} + +/// 获取并清除全局存储的 thoughtSignature +pub fn take_thought_signature() -> Option { + if let Ok(mut guard) = get_thought_sig_storage().lock() { + guard.take() + } else { + None + } +} + +/// 获取全局存储的 thoughtSignature(不清除) +pub fn get_thought_signature() -> Option { + if let Ok(guard) = get_thought_sig_storage().lock() { + guard.clone() + } else { + None + } +} + +pub fn create_openai_sse_stream( + mut gemini_stream: Pin> + Send>>, + model: String, +) -> Pin> + Send>> { + let mut buffer = BytesMut::new(); + + let stream = async_stream::stream! { + while let Some(item) = gemini_stream.next().await { + match item { + Ok(bytes) => { + // Verbose logging for debugging image fragmentation + debug!("[OpenAI-SSE] Received chunk: {} bytes", bytes.len()); + buffer.extend_from_slice(&bytes); + + // Process complete lines from buffer + while let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_raw = buffer.split_to(pos + 1); + if let Ok(line_str) = std::str::from_utf8(&line_raw) { + let line = line_str.trim(); + if line.is_empty() { continue; } + + if line.starts_with("data: ") { + let json_part = line.trim_start_matches("data: ").trim(); + if json_part == "[DONE]" { + continue; + } + + if let Ok(mut json) = serde_json::from_str::(json_part) { + // Log raw chunk for debugging gemini-3 thoughts + tracing::info!("Gemini SSE Chunk: {}", json_part); + + // Handle v1internal wrapper if present + let actual_data = if let Some(inner) = json.get_mut("response").map(|v| v.take()) { + inner + } else { + json + }; + + // Extract components + let candidates = actual_data.get("candidates").and_then(|c| c.as_array()); + let candidate = candidates.and_then(|c| c.get(0)); + let parts = candidate.and_then(|c| c.get("content")).and_then(|c| c.get("parts")).and_then(|p| p.as_array()); + + let mut content_out = String::new(); + + if let Some(parts_list) = parts { + for part in parts_list { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + content_out.push_str(text); + } + // Capture thought (Thinking Models) + if let Some(thought_text) = part.get("thought").and_then(|t| t.as_str()) { + // content_out.push_str(thought_text); + } + // 捕获 thoughtSignature (Gemini 3 工具调用必需) + if let Some(sig) = part.get("thoughtSignature").or(part.get("thought_signature")).and_then(|s| s.as_str()) { + store_thought_signature(sig); + } + + if let Some(img) = part.get("inlineData") { + let mime_type = img.get("mimeType").and_then(|v| v.as_str()).unwrap_or("image/png"); + let data = img.get("data").and_then(|v| v.as_str()).unwrap_or(""); + if !data.is_empty() { + content_out.push_str(&format!("![image](data:{};base64,{})", mime_type, data)); + } + } + } + } + + // 处理联网搜索引文 (Grounding Metadata) - 流式 + if let Some(grounding) = candidate.and_then(|c| c.get("groundingMetadata")) { + let mut grounding_text = String::new(); + if let Some(queries) = grounding.get("webSearchQueries").and_then(|q| q.as_array()) { + let query_list: Vec<&str> = queries.iter().filter_map(|v| v.as_str()).collect(); + if !query_list.is_empty() { + grounding_text.push_str("\n\n---\n**🔍 已为您搜索:** "); + grounding_text.push_str(&query_list.join(", ")); + } + } + + if let Some(chunks) = grounding.get("groundingChunks").and_then(|c| c.as_array()) { + let mut links = Vec::new(); + for (i, chunk) in chunks.iter().enumerate() { + if let Some(web) = chunk.get("web") { + let title = web.get("title").and_then(|v| v.as_str()).unwrap_or("网页来源"); + let uri = web.get("uri").and_then(|v| v.as_str()).unwrap_or("#"); + links.push(format!("[{}] [{}]({})", i + 1, title, uri)); + } + } + if !links.is_empty() { + grounding_text.push_str("\n\n**🌐 来源引文:**\n"); + grounding_text.push_str(&links.join("\n")); + } + } + if !grounding_text.is_empty() { + content_out.push_str(&grounding_text); + } + } + + if content_out.is_empty() { + // Skip empty chunks if no text/grounding was found + if candidate.and_then(|c| c.get("finishReason")).is_none() { + continue; + } + } + + // Extract finish reason + let finish_reason = candidate.and_then(|c| c.get("finishReason")) + .and_then(|f| f.as_str()) + .map(|f| match f { + "STOP" => "stop", + "MAX_TOKENS" => "length", + "SAFETY" => "content_filter", + _ => f, + }); + + // Construct OpenAI SSE chunk + let openai_chunk = json!({ + "id": format!("chatcmpl-{}", Uuid::new_v4()), + "object": "chat.completion.chunk", + "created": Utc::now().timestamp(), + "model": model, + "choices": [ + { + "index": 0, + "delta": { + "content": content_out + }, + "finish_reason": finish_reason + } + ] + }); + + let sse_out = format!("data: {}\n\n", serde_json::to_string(&openai_chunk).unwrap_or_default()); + yield Ok::(Bytes::from(sse_out)); + } + } + } + } + } + Err(e) => { + yield Err(format!("Upstream error: {}", e)); + } + } + } + // End of stream signal for OpenAI + yield Ok::(Bytes::from("data: [DONE]\n\n")); + }; + + Box::pin(stream) +} + +pub fn create_legacy_sse_stream( + mut gemini_stream: Pin> + Send>>, + model: String, +) -> Pin> + Send>> { + let mut buffer = BytesMut::new(); + + // Generate constant alphanumeric ID (mimics OpenAI base62 format) + let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + let random_str: String = (0..28) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset.chars().nth(idx).unwrap() + }) + .collect(); + let stream_id = format!("cmpl-{}", random_str); + let created_ts = Utc::now().timestamp(); + + let stream = async_stream::stream! { + while let Some(item) = gemini_stream.next().await { + match item { + Ok(bytes) => { + buffer.extend_from_slice(&bytes); + while let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_raw = buffer.split_to(pos + 1); + if let Ok(line_str) = std::str::from_utf8(&line_raw) { + let line = line_str.trim(); + if line.is_empty() { continue; } + + if line.starts_with("data: ") { + let json_part = line.trim_start_matches("data: ").trim(); + if json_part == "[DONE]" { continue; } + + if let Ok(mut json) = serde_json::from_str::(json_part) { + let actual_data = if let Some(inner) = json.get_mut("response").map(|v| v.take()) { inner } else { json }; + + let mut content_out = String::new(); + if let Some(candidates) = actual_data.get("candidates").and_then(|c| c.as_array()) { + if let Some(parts) = candidates.get(0).and_then(|c| c.get("content")).and_then(|c| c.get("parts")).and_then(|p| p.as_array()) { + for part in parts { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + content_out.push_str(text); + } + /* 禁用思维链输出到正文 + if let Some(thought_text) = part.get("thought").and_then(|t| t.as_str()) { + // // content_out.push_str(thought_text); + } + */ + // 捕获 thoughtSignature + // 捕获 thoughtSignature 到全局存储 + if let Some(sig) = part.get("thoughtSignature").or(part.get("thought_signature")).and_then(|s| s.as_str()) { + store_thought_signature(sig); + } + } + } + } + + let finish_reason = actual_data.get("candidates") + .and_then(|c| c.as_array()) + .and_then(|c| c.get(0)) + .and_then(|c| c.get("finishReason")) + .and_then(|f| f.as_str()) + .map(|f| match f { + "STOP" => "stop", + "MAX_TOKENS" => "length", + "SAFETY" => "content_filter", + _ => f, + }); + + // Construct LEGACY completion chunk - STRICT VERSION + let legacy_chunk = json!({ + "id": &stream_id, + "object": "text_completion", + "created": created_ts, + "model": &model, + "choices": [ + { + "text": content_out, + "index": 0, + "logprobs": null, + "finish_reason": finish_reason // Will be null if None + } + ] + }); + + let json_str = serde_json::to_string(&legacy_chunk).unwrap_or_default(); + tracing::info!("Legacy Stream Chunk: {}", json_str); + let sse_out = format!("data: {}\n\n", json_str); + yield Ok::(Bytes::from(sse_out)); + } + } + } + } + } + Err(e) => yield Err(format!("Upstream error: {}", e)), + } + } + tracing::info!("Stream finished. Yielding [DONE]"); + yield Ok::(Bytes::from("data: [DONE]\n\n")); + // Final flush delay + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }; + + Box::pin(stream) +} + +pub fn create_codex_sse_stream( + mut gemini_stream: Pin> + Send>>, + model: String, +) -> Pin> + Send>> { + let mut buffer = BytesMut::new(); + + // Generate alphanumeric ID + let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let mut rng = rand::thread_rng(); + let random_str: String = (0..24) + .map(|_| { + let idx = rng.gen_range(0..charset.len()); + charset.chars().nth(idx).unwrap() + }) + .collect(); + let response_id = format!("resp-{}", random_str); + + let stream = async_stream::stream! { + // 1. Emit response.created + let created_ev = json!({ + "type": "response.created", + "response": { + "id": &response_id, + "object": "response" + } + }); + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&created_ev).unwrap()))); + + let mut full_content = String::new(); + let mut emitted_tool_calls = std::collections::HashSet::new(); + let mut last_finish_reason = "stop".to_string(); + + while let Some(item) = gemini_stream.next().await { + match item { + Ok(bytes) => { + buffer.extend_from_slice(&bytes); + while let Some(pos) = buffer.iter().position(|&b| b == b'\n') { + let line_raw = buffer.split_to(pos + 1); + if let Ok(line_str) = std::str::from_utf8(&line_raw) { + let line = line_str.trim(); + if line.is_empty() || !line.starts_with("data: ") { continue; } + + let json_part = line.trim_start_matches("data: ").trim(); + if json_part == "[DONE]" { continue; } + + if let Ok(mut json) = serde_json::from_str::(json_part) { + let actual_data = if let Some(inner) = json.get_mut("response").map(|v| v.take()) { inner } else { json }; + + // Capture finish reason + if let Some(candidates) = actual_data.get("candidates").and_then(|c| c.as_array()) { + if let Some(candidate) = candidates.get(0) { + if let Some(reason) = candidate.get("finishReason").and_then(|r| r.as_str()) { + last_finish_reason = match reason { + "STOP" => "stop".to_string(), + "MAX_TOKENS" => "length".to_string(), + _ => "stop".to_string(), + }; + } + } + } + + // text delta + let mut delta_text = String::new(); + if let Some(candidates) = actual_data.get("candidates").and_then(|c| c.as_array()) { + if let Some(candidate) = candidates.get(0) { + if let Some(parts) = candidate.get("content").and_then(|c| c.get("parts")).and_then(|p| p.as_array()) { + for part in parts { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + // Sanitize smart quotes to standard quotes for JSON compatibility + let clean_text = text.replace('“', "\"").replace('”', "\""); + delta_text.push_str(&clean_text); + } + /* 禁用思维链输出到正文 + if let Some(thought_text) = part.get("thought").and_then(|t| t.as_str()) { + let clean_thought = thought_text.replace('"', "\"").replace('"', "\""); + // delta_text.push_str(&clean_thought); + } + */ + // 捕获 thoughtSignature (Gemini 3 工具调用必需) + // 存储到全局状态,不再嵌入到用户可见的文本中 + if let Some(sig) = part.get("thoughtSignature").or(part.get("thought_signature")).and_then(|s| s.as_str()) { + tracing::info!("[Codex-SSE] 捕获 thoughtSignature (长度: {})", sig.len()); + store_thought_signature(sig); + } + // Handle function call in chunk with deduplication + if let Some(func_call) = part.get("functionCall") { + let call_key = serde_json::to_string(func_call).unwrap_or_default(); + if !emitted_tool_calls.contains(&call_key) { + emitted_tool_calls.insert(call_key); + + let name = func_call.get("name").and_then(|v| v.as_str()).unwrap_or("unknown"); + let args = func_call.get("args").unwrap_or(&json!({})).to_string(); + + // Stable ID generation based on hashed content to be consistent + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + use std::hash::{Hash, Hasher}; + serde_json::to_string(func_call).unwrap_or_default().hash(&mut hasher); + let call_id = format!("call_{:x}", hasher.finish()); + + // Parse args once + let fallback_args = json!({}); + let args_obj = func_call.get("args").unwrap_or(&fallback_args); + // Fallback for function_call arguments string + let args_str = args_obj.to_string(); + + let name_str = name.to_string(); + + // Determine event type based on tool name + // 使用 Option 来允许某些情况跳过工具调用 + let maybe_item_added_ev: Option = if name_str == "shell" || name_str == "local_shell" { + // Map to local_shell_call + tracing::info!("[Debug] func_call: {}", serde_json::to_string(&func_call).unwrap_or_default()); + tracing::info!("[Debug] args_obj: {}", serde_json::to_string(&args_obj).unwrap_or_default()); + + // 解析命令:支持数组格式、字符串格式,以及空 args 情况 + let cmd_vec: Vec = if args_obj.as_object().map(|o| o.is_empty()).unwrap_or(true) { + // args 为空时使用静默成功命令,避免任务中断 + tracing::warn!("shell command args 为空,使用静默成功命令继续流程"); + vec!["powershell.exe".to_string(), "-Command".to_string(), "exit 0".to_string()] + } else if let Some(arr) = args_obj.get("command").and_then(|v| v.as_array()) { + // 数组格式 + arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect() + } else if let Some(cmd_str) = args_obj.get("command").and_then(|v| v.as_str()) { + // 字符串格式 + if cmd_str.contains(' ') { + vec!["powershell.exe".to_string(), "-Command".to_string(), cmd_str.to_string()] + } else { + vec![cmd_str.to_string()] + } + } else { + // command 字段缺失,使用静默成功命令 + tracing::warn!("shell command 缺少 command 字段,使用静默成功命令"); + vec!["powershell.exe".to_string(), "-Command".to_string(), "exit 0".to_string()] + }; + + tracing::info!("Shell 命令解析: {:?}", cmd_vec); + Some(json!({ + "type": "response.output_item.added", + "item": { + "type": "local_shell_call", + "status": "in_progress", + "call_id": &call_id, + "action": { + "type": "exec", + "command": cmd_vec + } + } + })) + } else if name_str == "googleSearch" || name_str == "web_search" || name_str == "google_search" { + // Map to web_search_call + let query_val = args_obj.get("query").and_then(|v| v.as_str()).unwrap_or(""); + Some(json!({ + "type": "response.output_item.added", + "item": { + "type": "web_search_call", + "status": "in_progress", + "call_id": &call_id, + "action": { + "type": "search", + "query": query_val + } + } + })) + } else { + // Default function_call + Some(json!({ + "type": "response.output_item.added", + "item": { + "type": "function_call", + "name": name, + "arguments": args_str, + "call_id": &call_id + } + })) + }; + + // 只有在有事件时才发送 + if let Some(item_added_ev) = maybe_item_added_ev { + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&item_added_ev).unwrap()))); + + // Emit response.output_item.done (matching the added event) + // 复用相同的 cmd_vec 逻辑 + let item_done_ev = if name_str == "shell" || name_str == "local_shell" { + let cmd_vec_done: Vec = if let Some(arr) = args_obj.get("command").and_then(|v| v.as_array()) { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + } else if let Some(cmd_str) = args_obj.get("command").and_then(|v| v.as_str()) { + if cmd_str.contains(' ') { + vec!["powershell.exe".to_string(), "-Command".to_string(), cmd_str.to_string()] + } else { + vec![cmd_str.to_string()] + } + } else { + vec!["powershell.exe".to_string(), "-Command".to_string(), "echo 'Invalid command'".to_string()] + }; + json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "status": "in_progress", + "call_id": call_id, + "action": { + "type": "exec", + "command": cmd_vec_done + } + } + }) + } else if name_str == "googleSearch" || name_str == "web_search" || name_str == "google_search" { + let query_val = args_obj.get("query").and_then(|v| v.as_str()).unwrap_or(""); + json!({ + "type": "response.output_item.done", + "item": { + "type": "web_search_call", + "status": "in_progress", + "call_id": call_id, + "action": { + "type": "search", + "query": query_val + } + } + }) + } else { + json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "name": name, + "arguments": args_str, + "call_id": call_id + } + }) + }; + + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&item_done_ev).unwrap()))); + } // 关闭 if let Some(item_added_ev) + } + } + } + } + } + } + + if !delta_text.is_empty() { + full_content.push_str(&delta_text); + // 2. Emit response.output_text.delta + let delta_ev = json!({ + "type": "response.output_text.delta", + "delta": delta_text + }); + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&delta_ev).unwrap()))); + } + } + } + } + } + Err(e) => yield Err(format!("Upstream error: {}", e)), + } + } + + // 3. Emit response.output_item.done + let item_done_ev = json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": full_content + } + ] + } + }); + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&item_done_ev).unwrap()))); + + // SSOP: Check full_content for embedded JSON command signatures if no tools were emitted natively + if emitted_tool_calls.is_empty() { + // Try to find a JSON block containing "command" + // Simple heuristic: look for { and } + // We search for the *last* valid JSON block that has a "command" field, as the model might output reasoning first. + + let mut detected_cmd_val = None; + let mut detected_cmd_type = "unknown"; + + // Find all potential JSON start/end indices + let chars: Vec = full_content.chars().collect(); + let mut depth = 0; + let mut start_idx = 0; + + // Scan for top-level JSON objects + for (i, c) in chars.iter().enumerate() { + if *c == '{' { + if depth == 0 { start_idx = i; } + depth += 1; + } else if *c == '}' { + if depth > 0 { + depth -= 1; + if depth == 0 { + // Found a potential JSON object block [start_idx..=i] + let json_str: String = chars[start_idx..=i].iter().collect(); + if let Ok(val) = serde_json::from_str::(&json_str) { + // Check for "command" field + if let Some(cmd_val) = val.get("command") { + // Found a command! Identify type. + // Case 1: "command": ["shell", ...] or ["ls", ...] + if let Some(arr) = cmd_val.as_array() { + if let Some(first) = arr.get(0).and_then(|v| v.as_str()) { + if first == "shell" || first == "powershell" || first == "cmd" || first == "ls" || first == "git" || first == "echo" { + detected_cmd_type = "shell"; + detected_cmd_val = Some(cmd_val.clone()); + } + } + } + // Case 2: "command": "shell" (String) and "args": { "command": "..." } + // This matches the user's latest screenshot which failed SSOP. + else if let Some(cmd_str) = cmd_val.as_str() { + if cmd_str == "shell" || cmd_str == "local_shell" { + // Enhanced matching for params/argument + if let Some(args) = val.get("args").or(val.get("arguments")).or(val.get("params")) { + if let Some(inner_cmd) = args.get("command").or(args.get("code")).or(args.get("argument")) { + // We construct a synthetic array: ["shell", inner_cmd] + // So subsequent logic can process it. + // Actually, let's just grab the inner command string. + if let Some(inner_cmd_str) = inner_cmd.as_str() { + detected_cmd_type = "shell"; + detected_cmd_val = Some(json!([inner_cmd_str])); + } + } + } + } + } + } + } else { + // Fallback for malformed JSON (e.g. unescaped quotes) + // 注意: 使用安全的切片方法避免 UTF-8 边界 panic + if (json_str.contains("\"command\": \"shell\"") || json_str.contains("\"command\": \"local_shell\"")) + && (json_str.contains("\"argument\":") || json_str.contains("\"code\":")) { + + let keys = ["\"argument\":", "\"code\":", "\"command\":"]; + for key in keys { + if let Some(pos) = json_str.find(key) { + // 使用安全的 get() 方法替代直接索引 + let slice_start = pos + key.len(); + if let Some(slice_after_key) = json_str.get(slice_start..) { + if let Some(quote_idx) = slice_after_key.find('"') { + let val_start_abs = slice_start + quote_idx + 1; + if let Some(last_quote_idx) = json_str.rfind('"') { + if last_quote_idx > val_start_abs { + // 使用 get() 安全获取子字符串 + if let Some(raw_cmd) = json_str.get(val_start_abs..last_quote_idx) { + detected_cmd_type = "shell"; + detected_cmd_val = Some(json!([raw_cmd])); + tracing::warn!("SSOP: Recovered malformed JSON command: {}", raw_cmd); + break; + } + } + } + } + } + } + } + } + } + } + } + } + } + + if let Some(cmd_val) = detected_cmd_val { + if detected_cmd_type == "shell" { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + use std::hash::{Hash, Hasher}; + "ssop_shell_call".hash(&mut hasher); // Unique seed + serde_json::to_string(&cmd_val).unwrap_or_default().hash(&mut hasher); + let call_id = format!("call_{:x}", hasher.finish()); + + let mut cmd_vec: Vec = cmd_val.as_array().unwrap().iter().map(|v| v.as_str().unwrap_or("").to_string()).collect(); + + // Helper to ensure it runs in shell properly + // Problem: Model often outputs ["shell", "powershell", "-Command", ...] + // "shell" is not a valid executable on Windows. We must strip it if it's acting as a label. + if !cmd_vec.is_empty() && (cmd_vec[0] == "shell" || cmd_vec[0] == "local_shell") { + cmd_vec.remove(0); + } + + // Now check if empty or needs wrapping + let final_cmd_vec = if cmd_vec.is_empty() { + vec!["powershell".to_string(), "-Command".to_string(), "echo 'Empty command'".to_string()] + } else if cmd_vec[0] == "powershell" || cmd_vec[0] == "cmd" || cmd_vec[0] == "git" || cmd_vec[0] == "python" || cmd_vec[0] == "node" { + cmd_vec + } else { + // Wrap generic commands (ls, dir, echo, etc) in powershell for Windows safety + // Use EncodedCommand to avoid quoting hell + // AND pipe to Out-String to avoid CLIXML object output which breaks Gemini + let raw_cmd = cmd_vec.join(" "); + let joined = format!("& {{ {} }} | Out-String", raw_cmd); + let utf16: Vec = joined.encode_utf16().collect(); + let mut bytes = Vec::with_capacity(utf16.len() * 2); + for c in utf16 { + bytes.extend_from_slice(&c.to_le_bytes()); + } + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + + vec!["powershell".to_string(), "-EncodedCommand".to_string(), b64] + }; + + tracing::info!("SSOP: Detected Shell Command in Text, Injecting Event: {:?}", final_cmd_vec); + + // Emit added + let item_added_ev = json!({ + "type": "response.output_item.added", + "item": { + "type": "local_shell_call", + "status": "in_progress", + "call_id": &call_id, + "action": { + "type": "exec", + "command": final_cmd_vec + } + } + }); + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&item_added_ev).unwrap()))); + + // Emit done + let item_done_ev = json!({ + "type": "response.output_item.done", + "item": { + "type": "local_shell_call", + "status": "in_progress", + "call_id": &call_id, + "action": { + "type": "exec", + "command": final_cmd_vec + } + } + }); + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&item_done_ev).unwrap()))); + } + } + } + + // 4. Emit response.completed + let completed_ev = json!({ + "type": "response.completed", + "response": { + "id": &response_id, + "object": "response", + "status": "completed", + "finish_reason": last_finish_reason, + "usage": { + "input_tokens": 0, + "input_tokens_details": { "cached_tokens": 0 }, + "output_tokens": 0, + "output_tokens_details": { "reasoning_tokens": 0 }, + "total_tokens": 0 + } + } + }); + yield Ok::(Bytes::from(format!("data: {}\n\n", serde_json::to_string(&completed_ev).unwrap()))); + }; + + Box::pin(stream) +} diff --git a/server/src/proxy/mappers/signature_store.rs b/server/src/proxy/mappers/signature_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..96847d2ba4735c849f3c015a46aefcb087cef2ce --- /dev/null +++ b/server/src/proxy/mappers/signature_store.rs @@ -0,0 +1,107 @@ +// Global thought_signature storage shared by all endpoints +// Used to capture and replay signatures for Gemini 3+ function calls when clients don't pass them back. + +use std::sync::{Mutex, OnceLock}; + +static GLOBAL_THOUGHT_SIG: OnceLock>> = OnceLock::new(); + +fn get_thought_sig_storage() -> &'static Mutex> { + GLOBAL_THOUGHT_SIG.get_or_init(|| Mutex::new(None)) +} + +/// Store thought_signature to global storage. +/// Only stores if the new signature is longer than the existing one, +/// to avoid short/partial signatures overwriting valid ones. +pub fn store_thought_signature(sig: &str) { + if let Ok(mut guard) = get_thought_sig_storage().lock() { + let should_store = match &*guard { + None => true, + Some(existing) => sig.len() > existing.len(), + }; + + if should_store { + tracing::info!( + "[ThoughtSig] Storing new signature (length: {}, replacing old length: {:?})", + sig.len(), + guard.as_ref().map(|s| s.len()) + ); + *guard = Some(sig.to_string()); + } else { + tracing::debug!( + "[ThoughtSig] Skipping shorter signature (new length: {}, existing length: {})", + sig.len(), + guard.as_ref().map(|s| s.len()).unwrap_or(0) + ); + } + } +} + +/// Get the stored thought_signature without clearing it. +pub fn get_thought_signature() -> Option { + if let Ok(guard) = get_thought_sig_storage().lock() { + guard.clone() + } else { + None + } +} + +/// Get and clear the stored thought_signature. +#[allow(dead_code)] +pub fn take_thought_signature() -> Option { + if let Ok(mut guard) = get_thought_sig_storage().lock() { + guard.take() + } else { + None + } +} + +/// Clear the stored thought_signature. +#[allow(dead_code)] +pub fn clear_thought_signature() { + if let Ok(mut guard) = get_thought_sig_storage().lock() { + *guard = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_signature_storage() { + // Clear any existing state + clear_thought_signature(); + + // Should be empty initially + assert!(get_thought_signature().is_none()); + + // Store a signature + store_thought_signature("test_signature_1234"); + assert_eq!( + get_thought_signature(), + Some("test_signature_1234".to_string()) + ); + + // Shorter signature should NOT overwrite + store_thought_signature("short"); + assert_eq!( + get_thought_signature(), + Some("test_signature_1234".to_string()) + ); + + // Longer signature SHOULD overwrite + store_thought_signature("test_signature_1234_longer_version"); + assert_eq!( + get_thought_signature(), + Some("test_signature_1234_longer_version".to_string()) + ); + + // Take should clear + let taken = take_thought_signature(); + assert_eq!( + taken, + Some("test_signature_1234_longer_version".to_string()) + ); + assert!(get_thought_signature().is_none()); + } +} diff --git a/server/src/proxy/middleware/auth.rs b/server/src/proxy/middleware/auth.rs new file mode 100644 index 0000000000000000000000000000000000000000..4ad62b0616f5c304819ebaed275028858a05bf65 --- /dev/null +++ b/server/src/proxy/middleware/auth.rs @@ -0,0 +1,161 @@ +// API Key Authentication Middleware +use axum::{ + extract::Request, + http::{header, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use once_cell::sync::Lazy; +use std::collections::HashSet; + +/// Load API keys from environment variables +/// Supports multiple formats: +/// - API_KEYS: comma-separated list (e.g., "key1,key2,key3") +/// - API_KEY_1, API_KEY_2, API_KEY_3, ... : individual keys +/// - API_KEY: single key (backward compatible) +fn load_api_keys() -> HashSet { + let mut keys = HashSet::new(); + + // Format 1: API_KEYS (comma-separated) + if let Ok(keys_str) = std::env::var("API_KEYS") { + for key in keys_str.split(',') { + let key = key.trim(); + if !key.is_empty() { + keys.insert(key.to_string()); + tracing::info!("Loaded API key from API_KEYS: {}...", &key[..key.len().min(8)]); + } + } + } + + // Format 2: API_KEY_1, API_KEY_2, etc. + for i in 1..=10 { + if let Ok(key) = std::env::var(format!("API_KEY_{}", i)) { + let key = key.trim().to_string(); + if !key.is_empty() { + tracing::info!("Loaded API key from API_KEY_{}: {}...", i, &key[..key.len().min(8)]); + keys.insert(key); + } + } + } + + // Format 3: Single API_KEY + if let Ok(key) = std::env::var("API_KEY") { + let key = key.trim().to_string(); + if !key.is_empty() { + tracing::info!("Loaded API key from API_KEY: {}...", &key[..key.len().min(8)]); + keys.insert(key); + } + } + + if keys.is_empty() { + tracing::warn!("No API keys configured! Set API_KEYS, API_KEY_1, or API_KEY environment variable."); + tracing::warn!("API authentication is DISABLED - all requests will be allowed."); + } else { + tracing::info!("Loaded {} API key(s) for authentication", keys.len()); + } + + keys +} + +/// Static API keys set (loaded once at startup) +static API_KEYS: Lazy> = Lazy::new(load_api_keys); + +/// Check if authentication is enabled +fn is_auth_enabled() -> bool { + !API_KEYS.is_empty() +} + +/// Validate API key +fn validate_api_key(key: &str) -> bool { + API_KEYS.contains(key) +} + +/// API Key authentication middleware for proxy endpoints +pub async fn auth_middleware(request: Request, next: Next) -> Result { + let path = request.uri().path(); + + // Log the request + tracing::debug!("Request: {} {}", request.method(), request.uri()); + + // Skip authentication for health check endpoints + if path == "/healthz" || path == "/api/health" { + return Ok(next.run(request).await); + } + + // Skip authentication for static files (frontend) + if !path.starts_with("/v1") && !path.starts_with("/api") { + return Ok(next.run(request).await); + } + + // Skip authentication for /api/* management endpoints + // (These should be protected by HuggingFace Spaces password) + if path.starts_with("/api/") { + return Ok(next.run(request).await); + } + + // If no API keys configured, allow all requests + if !is_auth_enabled() { + return Ok(next.run(request).await); + } + + // Extract API key from headers + // Support: Authorization: Bearer or X-API-Key: + let api_key = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|s| { + // Support both "Bearer " and just "" + s.strip_prefix("Bearer ").or(Some(s)) + }) + .or_else(|| { + request + .headers() + .get("x-api-key") + .and_then(|h| h.to_str().ok()) + }); + + match api_key { + Some(key) if validate_api_key(key) => { + tracing::debug!("API key validated successfully"); + Ok(next.run(request).await) + } + Some(_) => { + tracing::warn!("Invalid API key provided for {}", path); + Err(unauthorized_response("Invalid API key")) + } + None => { + tracing::warn!("No API key provided for {}", path); + Err(unauthorized_response("API key required. Provide via 'Authorization: Bearer ' or 'X-API-Key: ' header")) + } + } +} + +/// Generate unauthorized response +fn unauthorized_response(message: &str) -> Response { + let body = serde_json::json!({ + "error": { + "message": message, + "type": "authentication_error", + "code": "invalid_api_key" + } + }); + + ( + StatusCode::UNAUTHORIZED, + [(header::CONTENT_TYPE, "application/json")], + serde_json::to_string(&body).unwrap(), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_key_loading() { + // This test would need to be run with environment variables set + assert!(true); + } +} diff --git a/server/src/proxy/middleware/basic_auth.rs b/server/src/proxy/middleware/basic_auth.rs new file mode 100644 index 0000000000000000000000000000000000000000..262099cd6b436d4c7e5472561e7f2c95a6788792 --- /dev/null +++ b/server/src/proxy/middleware/basic_auth.rs @@ -0,0 +1,168 @@ +// Basic Authentication Middleware for Admin UI and API +use axum::{ + extract::Request, + http::{header, StatusCode}, + middleware::Next, + response::{IntoResponse, Response, Html}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use once_cell::sync::Lazy; + +/// Admin credentials from environment variables +struct AdminCredentials { + username: String, + password: String, + enabled: bool, +} + +/// Load admin credentials from environment +fn load_admin_credentials() -> AdminCredentials { + let username = std::env::var("ADMIN_USER") + .or_else(|_| std::env::var("ADMIN_USERNAME")) + .unwrap_or_default(); + let password = std::env::var("ADMIN_PASS") + .or_else(|_| std::env::var("ADMIN_PASSWORD")) + .unwrap_or_default(); + + let enabled = !username.is_empty() && !password.is_empty(); + + if enabled { + tracing::info!("Admin authentication enabled for user: {}", username); + } else { + tracing::warn!("Admin authentication DISABLED - set ADMIN_USER and ADMIN_PASS environment variables"); + } + + AdminCredentials { + username, + password, + enabled, + } +} + +/// Static admin credentials (loaded once at startup) +static ADMIN_CREDS: Lazy = Lazy::new(load_admin_credentials); + +/// Check if admin auth is enabled +pub fn is_admin_auth_enabled() -> bool { + ADMIN_CREDS.enabled +} + +/// Validate Basic Auth credentials +fn validate_basic_auth(auth_header: &str) -> bool { + if !ADMIN_CREDS.enabled { + return true; + } + + // Parse "Basic " + let encoded = match auth_header.strip_prefix("Basic ") { + Some(e) => e, + None => return false, + }; + + // Decode base64 + let decoded = match BASE64.decode(encoded) { + Ok(d) => d, + Err(_) => return false, + }; + + // Parse "username:password" + let decoded_str = match String::from_utf8(decoded) { + Ok(s) => s, + Err(_) => return false, + }; + + let parts: Vec<&str> = decoded_str.splitn(2, ':').collect(); + if parts.len() != 2 { + return false; + } + + parts[0] == ADMIN_CREDS.username && parts[1] == ADMIN_CREDS.password +} + +/// Basic Auth middleware for admin pages and API +pub async fn basic_auth_middleware(request: Request, next: Next) -> Result { + let path = request.uri().path(); + + // Skip auth for proxy endpoints (/v1/*) - they use API key auth + if path.starts_with("/v1") { + return Ok(next.run(request).await); + } + + // Skip auth for health check + if path == "/healthz" { + return Ok(next.run(request).await); + } + + // If admin auth is disabled, allow all + if !ADMIN_CREDS.enabled { + return Ok(next.run(request).await); + } + + // Check Authorization header + let auth_header = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()); + + match auth_header { + Some(header) if validate_basic_auth(header) => { + Ok(next.run(request).await) + } + _ => { + // Return 401 with WWW-Authenticate header + Err(unauthorized_response(path)) + } + } +} + +/// Generate 401 response with login prompt +fn unauthorized_response(path: &str) -> Response { + // For API endpoints, return JSON + if path.starts_with("/api/") { + let body = serde_json::json!({ + "success": false, + "error": "Authentication required. Set ADMIN_USER and ADMIN_PASS in HuggingFace Secrets." + }); + + return ( + StatusCode::UNAUTHORIZED, + [ + (header::CONTENT_TYPE, "application/json"), + (header::WWW_AUTHENTICATE, "Basic realm=\"Antigravity Admin\""), + ], + serde_json::to_string(&body).unwrap(), + ) + .into_response(); + } + + // For web pages, return HTML login prompt + ( + StatusCode::UNAUTHORIZED, + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::WWW_AUTHENTICATE, "Basic realm=\"Antigravity Admin\""), + ], + Html(r#" + + + + Login Required + + + +
+

Login Required

+

Please enter your credentials to access Antigravity Manager.

+

Configure ADMIN_USER and ADMIN_PASS in HuggingFace Secrets

+
+ + +"#.to_string()), + ) + .into_response() +} diff --git a/server/src/proxy/middleware/cors.rs b/server/src/proxy/middleware/cors.rs new file mode 100644 index 0000000000000000000000000000000000000000..63d036becf4ea361fc09cbff1bffeca301dd7a14 --- /dev/null +++ b/server/src/proxy/middleware/cors.rs @@ -0,0 +1,22 @@ +// CORS 中间件 +use tower_http::cors::{CorsLayer, Any}; + +/// 创建 CORS layer +pub fn cors_layer() -> CorsLayer { + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cors_layer_creation() { + let _layer = cors_layer(); + // Layer 创建成功 + assert!(true); + } +} diff --git a/server/src/proxy/middleware/logging.rs b/server/src/proxy/middleware/logging.rs new file mode 100644 index 0000000000000000000000000000000000000000..91015bf7ed6672eeba1c44f379479227e0e41b1e --- /dev/null +++ b/server/src/proxy/middleware/logging.rs @@ -0,0 +1,11 @@ +// 日志中间件 +// 直接使用 tower_http::trace::TraceLayer::new_for_http() 在路由中 + +#[cfg(test)] +mod tests { + #[test] + fn test_logging_middleware() { + // Logging middleware 通过 tower_http::trace::TraceLayer::new_for_http() 直接使用 + assert!(true); + } +} diff --git a/server/src/proxy/middleware/mod.rs b/server/src/proxy/middleware/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..e218bcf5b7d055f99c5b24f0224b3b4f5fa29608 --- /dev/null +++ b/server/src/proxy/middleware/mod.rs @@ -0,0 +1,10 @@ +// Middleware 模块 - Axum 中间件 + +pub mod auth; +pub mod basic_auth; +pub mod cors; +pub mod logging; + +pub use auth::auth_middleware; +pub use basic_auth::basic_auth_middleware; +pub use cors::cors_layer; diff --git a/server/src/proxy/mod.rs b/server/src/proxy/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..18082a29604e7d849fb7fbe588f6c891cb9020a5 --- /dev/null +++ b/server/src/proxy/mod.rs @@ -0,0 +1,18 @@ +// proxy 模块 - API 反代服务 + +// 现有模块 (保留) +pub mod config; +pub mod token_manager; +pub mod project_resolver; +pub mod server; + +// 新架构模块 +pub mod mappers; // 协议转换器 +pub mod handlers; // API 端点处理器 +pub mod middleware; // Axum 中间件 +pub mod upstream; // 上游客户端 +pub mod common; // 公共工具 + +pub use config::ProxyConfig; +pub use token_manager::TokenManager; +pub use server::AxumServer; diff --git a/server/src/proxy/project_resolver.rs b/server/src/proxy/project_resolver.rs new file mode 100644 index 0000000000000000000000000000000000000000..f65f028ae5d4f04419faaaee0cfe17b478119f9c --- /dev/null +++ b/server/src/proxy/project_resolver.rs @@ -0,0 +1,70 @@ +use serde_json::Value; + +/// 使用 Antigravity 的 loadCodeAssist API 获取 project_id +/// 这是获取 cloudaicompanionProject 的正确方式 +pub async fn fetch_project_id(access_token: &str) -> Result { + let url = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; + + let request_body = serde_json::json!({ + "metadata": { + "ideType": "ANTIGRAVITY" + } + }); + + let client = crate::utils::http::create_client(30); + let response = client + .post(url) + .bearer_auth(access_token) + .header("Host", "cloudcode-pa.googleapis.com") + .header("User-Agent", "antigravity/1.11.9 windows/amd64") + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|e| format!("loadCodeAssist 请求失败: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("loadCodeAssist 返回错误 {}: {}", status, body)); + } + + let data: Value = response.json() + .await + .map_err(|e| format!("解析响应失败: {}", e))?; + + // 提取 cloudaicompanionProject + if let Some(project_id) = data.get("cloudaicompanionProject") + .and_then(|v| v.as_str()) { + return Ok(project_id.to_string()); + } + + // 如果没有返回 project_id,说明账号无资格,使用内置随机生成逻辑作为兜底 + let mock_id = generate_mock_project_id(); + tracing::warn!("账号无资格获取官方 cloudaicompanionProject,将使用随机生成的 Project ID 作为兜底: {}", mock_id); + Ok(mock_id) +} + +/// 生成随机 project_id(当无法从 API 获取时使用) +/// 格式:{形容词}-{名词}-{5位随机字符} +pub fn generate_mock_project_id() -> String { + use rand::Rng; + + let adjectives = ["useful", "bright", "swift", "calm", "bold"]; + let nouns = ["fuze", "wave", "spark", "flow", "core"]; + + let mut rng = rand::thread_rng(); + let adj = adjectives[rng.gen_range(0..adjectives.len())]; + let noun = nouns[rng.gen_range(0..nouns.len())]; + + // 生成5位随机字符(base36) + let random_num: String = (0..5) + .map(|_| { + let chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let idx = rng.gen_range(0..chars.len()); + chars.chars().nth(idx).unwrap() + }) + .collect(); + + format!("{}-{}-{}", adj, noun, random_num) +} diff --git a/server/src/proxy/server.rs b/server/src/proxy/server.rs new file mode 100644 index 0000000000000000000000000000000000000000..1f971e6a97f87ad47de87d9736d1607dc742ba2c --- /dev/null +++ b/server/src/proxy/server.rs @@ -0,0 +1,193 @@ +use axum::{ + Router, + routing::{get, post}, + extract::DefaultBodyLimit, + response::{IntoResponse, Response, Json}, +}; +use tracing::{debug, error}; +use tower_http::trace::TraceLayer; +use std::sync::Arc; +use tokio::sync::oneshot; +use crate::proxy::TokenManager; + + +/// Axum 应用状态 +#[derive(Clone)] +pub struct AppState { + pub token_manager: Arc, + pub anthropic_mapping: Arc>>, + pub openai_mapping: Arc>>, + pub custom_mapping: Arc>>, + #[allow(dead_code)] + pub request_timeout: u64, // API 请求超时(秒) + #[allow(dead_code)] + pub thought_signature_map: Arc>>, // 思维链签名映射 (ID -> Signature) + #[allow(dead_code)] + pub upstream_proxy: Arc>, + pub upstream: Arc, +} + +/// Axum 服务器实例 +pub struct AxumServer { + shutdown_tx: Option>, + anthropic_mapping: Arc>>, + openai_mapping: Arc>>, + custom_mapping: Arc>>, + proxy_state: Arc>, +} + +impl AxumServer { + pub async fn update_mapping(&self, config: &crate::proxy::config::ProxyConfig) { + { + let mut m = self.anthropic_mapping.write().await; + *m = config.anthropic_mapping.clone(); + } + { + let mut m = self.openai_mapping.write().await; + *m = config.openai_mapping.clone(); + } + { + let mut m = self.custom_mapping.write().await; + *m = config.custom_mapping.clone(); + } + tracing::info!("模型映射 (Anthropic/OpenAI/Custom) 已全量热更新"); + } + + /// 更新代理配置 + pub async fn update_proxy(&self, new_config: crate::proxy::config::UpstreamProxyConfig) { + let mut proxy = self.proxy_state.write().await; + *proxy = new_config; + tracing::info!("上游代理配置已热更新"); + } + /// 启动 Axum 服务器 + pub async fn start( + host: String, + port: u16, + token_manager: Arc, + anthropic_mapping: std::collections::HashMap, + openai_mapping: std::collections::HashMap, + custom_mapping: std::collections::HashMap, + _request_timeout: u64, + upstream_proxy: crate::proxy::config::UpstreamProxyConfig, + ) -> Result<(Self, tokio::task::JoinHandle<()>), String> { + let mapping_state = Arc::new(tokio::sync::RwLock::new(anthropic_mapping)); + let openai_mapping_state = Arc::new(tokio::sync::RwLock::new(openai_mapping)); + let custom_mapping_state = Arc::new(tokio::sync::RwLock::new(custom_mapping)); + let proxy_state = Arc::new(tokio::sync::RwLock::new(upstream_proxy.clone())); + + let state = AppState { + token_manager: token_manager.clone(), + anthropic_mapping: mapping_state.clone(), + openai_mapping: openai_mapping_state.clone(), + custom_mapping: custom_mapping_state.clone(), + request_timeout: 300, // 5分钟超时 + thought_signature_map: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + upstream_proxy: proxy_state.clone(), + upstream: Arc::new(crate::proxy::upstream::client::UpstreamClient::new(Some(upstream_proxy.clone()))), + }; + + // 构建路由 - 使用新架构的 handlers! + use crate::proxy::handlers; + // 构建路由 + let app = Router::new() + // OpenAI Protocol + .route("/v1/models", get(handlers::openai::handle_list_models)) + .route("/v1/chat/completions", post(handlers::openai::handle_chat_completions)) + .route("/v1/completions", post(handlers::openai::handle_completions)) + .route("/v1/responses", post(handlers::openai::handle_completions)) // 兼容 Codex CLI + + // Claude Protocol + .route("/v1/messages", post(handlers::claude::handle_messages)) + .route("/v1/messages/count_tokens", post(handlers::claude::handle_count_tokens)) + .route("/v1/models/claude", get(handlers::claude::handle_list_models)) + + // Gemini Protocol (Native) + .route("/v1beta/models", get(handlers::gemini::handle_list_models)) + // Handle both GET (get info) and POST (generateContent with colon) at the same route + .route("/v1beta/models/:model", get(handlers::gemini::handle_get_model).post(handlers::gemini::handle_generate)) + .route("/v1beta/models/:model/countTokens", post(handlers::gemini::handle_count_tokens)) // Specific route priority + .route("/healthz", get(health_check_handler)) + .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) + .layer(TraceLayer::new_for_http()) + .layer(axum::middleware::from_fn(crate::proxy::middleware::auth_middleware)) + .layer(crate::proxy::middleware::cors_layer()) + .with_state(state); + + // 绑定地址 + let addr = format!("{}:{}", host, port); + let listener = tokio::net::TcpListener::bind(&addr) + .await + .map_err(|e| format!("地址 {} 绑定失败: {}", addr, e))?; + + tracing::info!("反代服务器启动在 http://{}", addr); + + // 创建关闭通道 + let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); + + let server_instance = Self { + shutdown_tx: Some(shutdown_tx), + anthropic_mapping: mapping_state.clone(), + openai_mapping: openai_mapping_state.clone(), + custom_mapping: custom_mapping_state.clone(), + proxy_state, + }; + + // 在新任务中启动服务器 + let handle = tokio::spawn(async move { + use hyper_util::rt::TokioIo; + use hyper::server::conn::http1; + use hyper_util::service::TowerToHyperService; + + loop { + tokio::select! { + res = listener.accept() => { + match res { + Ok((stream, _)) => { + let io = TokioIo::new(stream); + let service = TowerToHyperService::new(app.clone()); + + tokio::task::spawn(async move { + if let Err(err) = http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() // 支持 WebSocket (如果以后需要) + .await + { + debug!("连接处理结束或出错: {:?}", err); + } + }); + } + Err(e) => { + error!("接收连接失败: {:?}", e); + } + } + } + _ = &mut shutdown_rx => { + tracing::info!("反代服务器停止监听"); + break; + } + } + } + }); + + Ok(( + server_instance, + handle, + )) + } + + /// 停止服务器 + pub fn stop(mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +// ===== API 处理器 (旧代码已移除,由 src/proxy/handlers/* 接管) ===== + +/// 健康检查处理器 +async fn health_check_handler() -> Response { + Json(serde_json::json!({ + "status": "ok" + })).into_response() +} diff --git a/server/src/proxy/token_manager.rs b/server/src/proxy/token_manager.rs new file mode 100644 index 0000000000000000000000000000000000000000..1f3c8711d65542140b7595c4fcd2e94a54081f3c --- /dev/null +++ b/server/src/proxy/token_manager.rs @@ -0,0 +1,302 @@ +// 移除冗余的顶层导入,因为这些在代码中已由 full path 或局部导入处理 +use dashmap::DashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct ProxyToken { + pub account_id: String, + pub access_token: String, + pub refresh_token: String, + pub expires_in: i64, + pub timestamp: i64, + pub email: String, + pub account_path: PathBuf, // 账号文件路径,用于更新 + pub project_id: Option, +} + +pub struct TokenManager { + tokens: Arc>, // account_id -> ProxyToken + current_index: Arc, + last_used_account: Arc>>, + data_dir: PathBuf, +} + +impl TokenManager { + /// 创建新的 TokenManager + pub fn new(data_dir: PathBuf) -> Self { + Self { + tokens: Arc::new(DashMap::new()), + current_index: Arc::new(AtomicUsize::new(0)), + last_used_account: Arc::new(tokio::sync::Mutex::new(None)), + data_dir, + } + } + + /// 从主应用账号目录加载所有账号 + pub async fn load_accounts(&self) -> Result { + let accounts_dir = self.data_dir.join("accounts"); + + if !accounts_dir.exists() { + return Err(format!("账号目录不存在: {:?}", accounts_dir)); + } + + let entries = std::fs::read_dir(&accounts_dir) + .map_err(|e| format!("读取账号目录失败: {}", e))?; + + let mut count = 0; + + for entry in entries { + let entry = entry.map_err(|e| format!("读取目录项失败: {}", e))?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + + // 尝试加载账号 + match self.load_single_account(&path).await { + Ok(Some(token)) => { + let account_id = token.account_id.clone(); + self.tokens.insert(account_id, token); + count += 1; + }, + Ok(None) => { + // 跳过无效账号 + }, + Err(e) => { + tracing::warn!("加载账号失败 {:?}: {}", path, e); + } + } + } + + Ok(count) + } + + /// 加载单个账号 + async fn load_single_account(&self, path: &PathBuf) -> Result, String> { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("读取文件失败: {}", e))?; + + let account: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| format!("解析 JSON 失败: {}", e))?; + + let account_id = account["id"].as_str() + .ok_or("缺少 id 字段")? + .to_string(); + + let email = account["email"].as_str() + .ok_or("缺少 email 字段")? + .to_string(); + + let token_obj = account["token"].as_object() + .ok_or("缺少 token 字段")?; + + let access_token = token_obj["access_token"].as_str() + .ok_or("缺少 access_token")? + .to_string(); + + let refresh_token = token_obj["refresh_token"].as_str() + .ok_or("缺少 refresh_token")? + .to_string(); + + let expires_in = token_obj["expires_in"].as_i64() + .ok_or("缺少 expires_in")?; + + let timestamp = token_obj["expiry_timestamp"].as_i64() + .ok_or("缺少 expiry_timestamp")?; + + // project_id 是可选的 + let project_id = token_obj.get("project_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(Some(ProxyToken { + account_id, + access_token, + refresh_token, + expires_in, + timestamp, + email, + account_path: path.clone(), + project_id, + })) + } + + /// 获取当前可用的 Token(带 60s 时间窗口锁定机制) + /// 参数 `_quota_group` 用于区分 "claude" vs "gemini" 组 + /// 参数 `force_rotate` 为 true 时将忽略锁定,强制切换账号 + pub async fn get_token(&self, quota_group: &str, force_rotate: bool) -> Result<(String, String, String), String> { + let total = self.tokens.len(); + if total == 0 { + return Err("Token pool is empty".to_string()); + } + + // 1. 检查时间窗口锁定 (60秒内强制复用上一个账号) + // 优化策略: 画图请求 (image_gen) 默认不锁定,以最大化并发能力 + let mut target_token = None; + if !force_rotate && quota_group != "image_gen" { + let last_used = self.last_used_account.lock().await; + if let Some((account_id, last_time)) = &*last_used { + if last_time.elapsed().as_secs() < 60 { + if let Some(entry) = self.tokens.get(account_id) { + tracing::info!("60s 时间窗口内,强制复用上一个账号: {}", entry.email); + target_token = Some(entry.value().clone()); + } + } + } + } + + // 2. 如果没有锁定、锁定失效或强制轮换,则进行轮询记录并更新锁定信息 + let mut token = if let Some(t) = target_token { + t + } else { + // 简单轮换策略 (Round Robin) + let idx = self.current_index.fetch_add(1, Ordering::SeqCst) % total; + let selected_token = self.tokens.iter() + .nth(idx) + .map(|entry| entry.value().clone()) + .ok_or("Failed to retrieve token from pool")?; + + // 更新最后使用的账号及时间 (如果是普通对话请求) + if quota_group != "image_gen" { + let mut last_used = self.last_used_account.lock().await; + *last_used = Some((selected_token.account_id.clone(), std::time::Instant::now())); + } + + let action_msg = if force_rotate { "强制切换" } else { "切换" }; + tracing::info!("{}到账号: {}", action_msg, selected_token.email); + selected_token + }; + + // 3. 检查 token 是否过期(提前5分钟刷新) + let now = chrono::Utc::now().timestamp(); + if now >= token.timestamp - 300 { + tracing::info!("账号 {} 的 token 即将过期,正在刷新...", token.email); + + // 调用 OAuth 刷新 token + match crate::modules::oauth::refresh_access_token(&token.refresh_token).await { + Ok(token_response) => { + tracing::info!("Token 刷新成功!"); + + // 更新本地内存对象供后续使用 + token.access_token = token_response.access_token.clone(); + token.expires_in = token_response.expires_in; + token.timestamp = now + token_response.expires_in; + + // 同步更新跨线程共享的 DashMap + if let Some(mut entry) = self.tokens.get_mut(&token.account_id) { + entry.access_token = token.access_token.clone(); + entry.expires_in = token.expires_in; + entry.timestamp = token.timestamp; + } + } + Err(e) => { + tracing::error!("Token 刷新失败: {},尝试下一个账号", e); + return Err(format!("Token refresh failed: {}", e)); + } + } + } + + // 4. 确保有 project_id + let project_id = if let Some(pid) = &token.project_id { + pid.clone() + } else { + tracing::info!("账号 {} 缺少 project_id,尝试获取...", token.email); + match crate::proxy::project_resolver::fetch_project_id(&token.access_token).await { + Ok(pid) => { + if let Some(mut entry) = self.tokens.get_mut(&token.account_id) { + entry.project_id = Some(pid.clone()); + } + let _ = self.save_project_id(&token.account_id, &pid).await; + pid + } + Err(e) => { + tracing::error!("Failed to fetch project_id for {}: {}", token.email, e); + return Err(format!("Failed to fetch project_id: {}", e)); + } + } + }; + + Ok((token.access_token, project_id, token.email)) + } + + /// 保存 project_id 到账号文件 + async fn save_project_id(&self, account_id: &str, project_id: &str) -> Result<(), String> { + let entry = self.tokens.get(account_id) + .ok_or("账号不存在")?; + + let path = &entry.account_path; + + let mut content: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))? + ).map_err(|e| format!("解析 JSON 失败: {}", e))?; + + content["token"]["project_id"] = serde_json::Value::String(project_id.to_string()); + + std::fs::write(path, serde_json::to_string_pretty(&content).unwrap()) + .map_err(|e| format!("写入文件失败: {}", e))?; + + tracing::info!("已保存 project_id 到账号 {}", account_id); + Ok(()) + } + + /// 保存刷新后的 token 到账号文件 + #[allow(dead_code)] + async fn save_refreshed_token(&self, account_id: &str, token_response: &crate::modules::oauth::TokenResponse) -> Result<(), String> { + let entry = self.tokens.get(account_id) + .ok_or("账号不存在")?; + + let path = &entry.account_path; + + let mut content: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}", e))? + ).map_err(|e| format!("解析 JSON 失败: {}", e))?; + + let now = chrono::Utc::now().timestamp(); + + content["token"]["access_token"] = serde_json::Value::String(token_response.access_token.clone()); + content["token"]["expires_in"] = serde_json::Value::Number(token_response.expires_in.into()); + content["token"]["expiry_timestamp"] = serde_json::Value::Number((now + token_response.expires_in).into()); + + std::fs::write(path, serde_json::to_string_pretty(&content).unwrap()) + .map_err(|e| format!("写入文件失败: {}", e))?; + + tracing::info!("已保存刷新后的 token 到账号 {}", account_id); + Ok(()) + } + + pub fn len(&self) -> usize { + self.tokens.len() + } + + /// Get token count (alias for len) + pub async fn token_count(&self) -> usize { + self.tokens.len() + } + + /// Add a token manually + pub async fn add_token( + &self, + access_token: String, + refresh_token: String, + expiry_timestamp: i64, + email: String, + project_id: Option, + ) { + let account_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().timestamp(); + let token = ProxyToken { + account_id: account_id.clone(), + access_token, + refresh_token, + expires_in: expiry_timestamp - now, + timestamp: expiry_timestamp, + email, + account_path: self.data_dir.join("accounts").join(format!("{}.json", account_id)), + project_id, + }; + self.tokens.insert(account_id, token); + } +} diff --git a/server/src/proxy/upstream/client.rs b/server/src/proxy/upstream/client.rs new file mode 100644 index 0000000000000000000000000000000000000000..4ba19f9c4640d189812406789894d2bd0c00d964 --- /dev/null +++ b/server/src/proxy/upstream/client.rs @@ -0,0 +1,144 @@ +// 上游客户端实现 +// 基于高性能通讯接口封装 + +use reqwest::{header, Client, Response}; +use serde_json::Value; +use tokio::time::Duration; + +// 生产环境端点 +const V1_INTERNAL_BASE_URL: &str = "https://cloudcode-pa.googleapis.com/v1internal"; + +pub struct UpstreamClient { + http_client: Client, +} + +impl UpstreamClient { + pub fn new(proxy_config: Option) -> Self { + let mut builder = Client::builder() + .timeout(Duration::from_secs(600)) + .user_agent("antigravity/1.11.9 windows/amd64"); + + if let Some(config) = proxy_config { + if config.enabled && !config.url.is_empty() { + if let Ok(proxy) = reqwest::Proxy::all(&config.url) { + builder = builder.proxy(proxy); + tracing::info!("UpstreamClient enabled proxy: {}", config.url); + } + } + } + + let http_client = builder.build().expect("Failed to create HTTP client"); + + Self { http_client } + } + + /// 构建 v1internal URL + /// + /// 构建 API 请求地址 + fn build_url(method: &str, query_string: Option<&str>) -> String { + if let Some(qs) = query_string { + format!("{}:{}?{}", V1_INTERNAL_BASE_URL, method, qs) + } else { + format!("{}:{}", V1_INTERNAL_BASE_URL, method) + } + } + + /// 调用 v1internal API(基础方法) + /// + /// 发起基础网络请求 + pub async fn call_v1_internal( + &self, + method: &str, + access_token: &str, + body: Value, + query_string: Option<&str>, + ) -> Result { + let url = Self::build_url(method, query_string); + + // 构建 Headers + let mut headers = header::HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static("application/json")); + headers.insert(header::AUTHORIZATION, header::HeaderValue::from_str(&format!("Bearer {}", access_token)).map_err(|e| e.to_string())?); + // 设置自定义 User-Agent + headers.insert(header::USER_AGENT, header::HeaderValue::from_static("antigravity/1.11.9 windows/amd64")); + + // 记录请求详情以便调试 404 + let response = self + .http_client + .post(&url) + .headers(headers) // Apply all headers at once + .json(&body) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + Ok(response) + } + + /// 调用 v1internal API(带 429 重试,支持闭包) + /// + /// 带容错和重试的核心请求逻辑 + /// + /// # Arguments + /// * `method` - API method (e.g., "generateContent") + /// * `query_string` - Optional query string (e.g., "?alt=sse") + /// * `get_credentials` - 闭包,获取凭证(支持账号轮换) + /// * `build_body` - 闭包,接收 project_id 构建请求体 + /// * `max_attempts` - 最大重试次数 + /// + /// # Returns + /// HTTP Response + // 已移除弃用的重试方法 (call_v1_internal_with_retry) + + // 已移除弃用的辅助方法 (parse_retry_delay) + + // 已移除弃用的辅助方法 (parse_duration_ms) + + /// 获取可用模型列表 + /// + /// 获取远端模型列表 + pub async fn fetch_available_models(&self, access_token: &str) -> Result { + let url = Self::build_url("fetchAvailableModels", None); + + let mut headers = header::HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, header::HeaderValue::from_static("application/json")); + headers.insert(header::AUTHORIZATION, header::HeaderValue::from_str(&format!("Bearer {}", access_token)).map_err(|e| e.to_string())?); + headers.insert(header::USER_AGENT, header::HeaderValue::from_static("antigravity/1.11.9 windows/amd64")); + + let response = self.http_client + .post(&url) + .headers(headers) + .json(&serde_json::json!({})) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Upstream error: {}", response.status())); + } + + let json: Value = response.json().await.map_err(|e| format!("Parse json failed: {}", e))?; + Ok(json) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_url() { + let url1 = UpstreamClient::build_url("generateContent", None); + assert_eq!( + url1, + "https://cloudcode-pa.googleapis.com/v1internal:generateContent" + ); + + let url2 = UpstreamClient::build_url("streamGenerateContent", Some("alt=sse")); + assert_eq!( + url2, + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse" + ); + } + +} diff --git a/server/src/proxy/upstream/mod.rs b/server/src/proxy/upstream/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca6e6af8f1e841c929f8f0737d1ffec9c12ffd01 --- /dev/null +++ b/server/src/proxy/upstream/mod.rs @@ -0,0 +1,6 @@ +// Upstream 模块 - 上游客户端 +// 对应上游通讯接口 + +pub mod client; +pub mod retry; +pub mod models; diff --git a/server/src/proxy/upstream/models.rs b/server/src/proxy/upstream/models.rs new file mode 100644 index 0000000000000000000000000000000000000000..4b20a76696b76deaaddf73bfd4db34011e2d6d31 --- /dev/null +++ b/server/src/proxy/upstream/models.rs @@ -0,0 +1,4 @@ +// 上游 API 模型 +pub struct UpstreamModels { + // TODO: Phase 3 +} diff --git a/server/src/proxy/upstream/retry.rs b/server/src/proxy/upstream/retry.rs new file mode 100644 index 0000000000000000000000000000000000000000..452ae7ef8b58722697dfbb0babec1345dc352263 --- /dev/null +++ b/server/src/proxy/upstream/retry.rs @@ -0,0 +1,94 @@ +// 429 重试策略 +// Duration 解析 + +use regex::Regex; +use once_cell::sync::Lazy; + +static DURATION_RE: Lazy = Lazy::new(|| { + Regex::new(r"([\d.]+)\s*(ms|s|m|h)").unwrap() +}); + +/// 解析 Duration 字符串 (e.g., "1.5s", "200ms", "1h16m0.667s") +pub fn parse_duration_ms(duration_str: &str) -> Option { + let mut total_ms: f64 = 0.0; + let mut matched = false; + + for cap in DURATION_RE.captures_iter(duration_str) { + matched = true; + let value: f64 = cap[1].parse().ok()?; + let unit = &cap[2]; + + match unit { + "ms" => total_ms += value, + "s" => total_ms += value * 1000.0, + "m" => total_ms += value * 60.0 * 1000.0, + "h" => total_ms += value * 60.0 * 60.0 * 1000.0, + _ => {} + } + } + + if !matched { + return None; + } + + Some(total_ms.round() as u64) +} + +/// 从 429 错误中提取 retry delay +pub fn parse_retry_delay(error_text: &str) -> Option { + use serde_json::Value; + + let json: Value = serde_json::from_str(error_text).ok()?; + let details = json.get("error")?.get("details")?.as_array()?; + + // 方式1: RetryInfo.retryDelay + for detail in details { + if let Some(type_str) = detail.get("@type").and_then(|v| v.as_str()) { + if type_str.contains("RetryInfo") { + if let Some(retry_delay) = detail.get("retryDelay").and_then(|v| v.as_str()) { + return parse_duration_ms(retry_delay); + } + } + } + } + + // 方式2: metadata.quotaResetDelay + for detail in details { + if let Some(quota_delay) = detail + .get("metadata") + .and_then(|m| m.get("quotaResetDelay")) + .and_then(|v| v.as_str()) + { + return parse_duration_ms(quota_delay); + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_duration_ms() { + assert_eq!(parse_duration_ms("1.5s"), Some(1500)); + assert_eq!(parse_duration_ms("200ms"), Some(200)); + assert_eq!(parse_duration_ms("1h16m0.667s"), Some(4560667)); + assert_eq!(parse_duration_ms("invalid"), None); + } + + #[test] + fn test_parse_retry_delay() { + let error_json = r#"{ + "error": { + "details": [{ + "@type": "type.googleapis.com/google.rpc.RetryInfo", + "retryDelay": "1.203608125s" + }] + } + }"#; + + assert_eq!(parse_retry_delay(error_json), Some(1204)); + } +} diff --git a/server/src/utils/http.rs b/server/src/utils/http.rs new file mode 100644 index 0000000000000000000000000000000000000000..7fe4fa6fdc6bc06735be810044e95571271f3127 --- /dev/null +++ b/server/src/utils/http.rs @@ -0,0 +1,36 @@ +use reqwest::{Client, Proxy}; +use crate::modules::config::load_app_config; + +/// Create a unified HTTP client with global configuration +pub fn create_client(timeout_secs: u64) -> Client { + if let Ok(config) = load_app_config() { + create_client_with_proxy(timeout_secs, Some(config.proxy.upstream_proxy)) + } else { + create_client_with_proxy(timeout_secs, None) + } +} + +/// Create HTTP client with specific proxy configuration +pub fn create_client_with_proxy( + timeout_secs: u64, + proxy_config: Option +) -> Client { + let mut builder = Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)); + + if let Some(config) = proxy_config { + if config.enabled && !config.url.is_empty() { + match Proxy::all(&config.url) { + Ok(proxy) => { + builder = builder.proxy(proxy); + tracing::info!("HTTP client using upstream proxy: {}", config.url); + } + Err(e) => { + tracing::error!("Invalid proxy address: {}, error: {}", config.url, e); + } + } + } + } + + builder.build().unwrap_or_else(|_| Client::new()) +} diff --git a/server/src/utils/mod.rs b/server/src/utils/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..3883215fcb6373c78b7d1ca710c4b048a5aba482 --- /dev/null +++ b/server/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod http; diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..c40141f11e6e9f6940abd9afaa777036f3071830 --- /dev/null +++ b/src/App.css @@ -0,0 +1,101 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* 禁止过度滚动和橡皮筋效果 */ +html, +body { + overscroll-behavior: none; + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + border: none; +} + +html { + background-color: #FAFBFC; +} + +html.dark { + background-color: #1d232a; +} + +/* 全局样式 */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #FAFBFC; +} + +/* Dark mode override for body strictly */ +.dark body { + background-color: #1d232a; + /* matches base-300 commonly used */ +} + +#root { + width: 100%; + height: 100%; + overflow-y: auto; + overscroll-behavior: none; +} + +/* 移除默认的 tap 高亮 */ +* { + -webkit-tap-highlight-color: transparent; +} + +/* 只移除链接的默认下划线,不强制颜色 */ +a { + text-decoration: none; +} + +/* 滚动条优化 - 彻底隐藏但保留功能 */ +::-webkit-scrollbar { + width: 0px; + background: transparent; +} + +::-webkit-scrollbar-track { + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 99px; + border: 3px solid transparent; + background-clip: content-box; + transition: background-color 0.2s; +} + +::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.3); +} + +/* View Transitions API 主题切换动画 */ +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + +::view-transition-old(root) { + z-index: 1; +} + +::view-transition-new(root) { + z-index: 9999; +} + +.dark::view-transition-old(root) { + z-index: 9999; +} + +.dark::view-transition-new(root) { + z-index: 1; +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c9d4c248cad5c094e82f5880819a68deffefed3 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,61 @@ +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; + +import Layout from './components/layout/Layout'; +import Dashboard from './pages/Dashboard'; +import Accounts from './pages/Accounts'; +import Settings from './pages/Settings'; +import ApiProxy from './pages/ApiProxy'; +import ThemeManager from './components/common/ThemeManager'; +import { useEffect } from 'react'; +import { useConfigStore } from './stores/useConfigStore'; +import { useTranslation } from 'react-i18next'; + +const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'accounts', + element: , + }, + { + path: 'api-proxy', + element: , + }, + { + path: 'settings', + element: , + }, + ], + }, +]); + +function App() { + const { config, loadConfig } = useConfigStore(); + const { i18n } = useTranslation(); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + // Sync language from config + useEffect(() => { + if (config?.language) { + i18n.changeLanguage(config.language); + } + }, [config?.language, i18n]); + + return ( + <> + + + + ); +} + +export default App; diff --git a/src/components/accounts/AccountCard.tsx b/src/components/accounts/AccountCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b83aa230456bc1a9d5d768c7177ee21ce3a2d54 --- /dev/null +++ b/src/components/accounts/AccountCard.tsx @@ -0,0 +1,298 @@ +import { ArrowRightLeft, RefreshCw, Trash2, Download, Info, Lock, Ban, Diamond, Gem, Circle, Clock } from 'lucide-react'; +import { Account } from '../../types/account'; +import { getQuotaColor, formatTimeRemaining, getTimeRemainingColor } from '../../utils/format'; +import { cn } from '../../utils/cn'; +import { useTranslation } from 'react-i18next'; + +interface AccountCardProps { + account: Account; + selected: boolean; + onSelect: () => void; + isCurrent: boolean; + isRefreshing: boolean; + isSwitching?: boolean; + onSwitch: () => void; + onRefresh: () => void; + onViewDetails: () => void; + onExport: () => void; + onDelete: () => void; +} + +function AccountCard({ account, selected, onSelect, isCurrent, isRefreshing, isSwitching = false, onSwitch, onRefresh, onViewDetails, onExport, onDelete }: AccountCardProps) { + const { t } = useTranslation(); + const geminiProModel = account.quota?.models.find(m => m.name === 'gemini-3-pro-high'); + const geminiFlashModel = account.quota?.models.find(m => m.name === 'gemini-3-flash'); + const geminiImageModel = account.quota?.models.find(m => m.name === 'gemini-3-pro-image'); + const claudeModel = account.quota?.models.find(m => m.name === 'claude-sonnet-4-5-thinking'); + + const getColorClass = (percentage: number) => { + const color = getQuotaColor(percentage); + switch (color) { + case 'success': return 'bg-emerald-500'; + case 'warning': return 'bg-amber-500'; + case 'error': return 'bg-rose-500'; + default: return 'bg-gray-500'; + } + }; + + const getTimeColorClass = (resetTime: string | undefined) => { + const color = getTimeRemainingColor(resetTime); + switch (color) { + case 'success': return 'text-emerald-500 dark:text-emerald-400'; + case 'warning': return 'text-amber-500 dark:text-amber-400'; + default: return 'text-gray-400 dark:text-gray-500 opacity-60'; + } + }; + + return ( +
+ + {/* Header: Checkbox + Email + Badges */} +
+ onSelect()} + onClick={(e) => e.stopPropagation()} + /> +
+
+

+ {account.email} +

+
+ {isCurrent && ( + + {t('accounts.current').toUpperCase()} + + )} + {account.quota?.is_forbidden && ( + + + {t('accounts.forbidden').toUpperCase()} + + )} + {/* 订阅类型徽章 */} + {account.quota?.subscription_tier && (() => { + const tier = account.quota.subscription_tier.toLowerCase(); + if (tier.includes('ultra')) { + return ( + + + ULTRA + + ); + } else if (tier.includes('pro')) { + return ( + + + PRO + + ); + } else { + return ( + + + FREE + + ); + } + })()} +
+
+
+
+ + {/* Quota Section */} +
+ {account.quota?.is_forbidden ? ( +
+ + {t('accounts.forbidden_msg')} +
+ ) : ( + <> +
+ {/* Gemini Pro */} +
+ {geminiProModel && ( +
+ )} +
+ G3 Pro +
+ {geminiProModel?.reset_time ? ( + + + {formatTimeRemaining(geminiProModel.reset_time)} + + ) : ( + N/A + )} +
+ + {(geminiProModel?.percentage || 0)}% + +
+
+ + {/* Gemini Flash */} +
+ {geminiFlashModel && ( +
+ )} +
+ G3 Flash +
+ {geminiFlashModel?.reset_time ? ( + + + {formatTimeRemaining(geminiFlashModel.reset_time)} + + ) : ( + N/A + )} +
+ + {(geminiFlashModel?.percentage || 0)}% + +
+
+ + {/* Gemini Image */} +
+ {geminiImageModel && ( +
+ )} +
+ G3 Image +
+ {geminiImageModel?.reset_time ? ( + + + {formatTimeRemaining(geminiImageModel.reset_time)} + + ) : ( + N/A + )} +
+ + {(geminiImageModel?.percentage || 0)}% + +
+
+ + {/* Claude */} +
+ {claudeModel && ( +
+ )} +
+ Claude 4.5 +
+ {claudeModel?.reset_time ? ( + + + {formatTimeRemaining(claudeModel.reset_time)} + + ) : ( + N/A + )} +
+ + {(claudeModel?.percentage || 0)}% + +
+
+
+ + )} +
+ + {/* Footer: Actions & Date */} +
+ + {new Date(account.last_used * 1000).toLocaleString([], { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} + + +
+ + + + + +
+
+
+ ); +} + +export default AccountCard; diff --git a/src/components/accounts/AccountDetailsDialog.tsx b/src/components/accounts/AccountDetailsDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d869a059f69a5a840581e5e702d45ea3e5ca183 --- /dev/null +++ b/src/components/accounts/AccountDetailsDialog.tsx @@ -0,0 +1,84 @@ +import { X, Clock, AlertCircle } from 'lucide-react'; +import { createPortal } from 'react-dom'; +import { Account, ModelQuota } from '../../types/account'; +import { formatDate } from '../../utils/format'; +import { useTranslation } from 'react-i18next'; + +interface AccountDetailsDialogProps { + account: Account | null; + onClose: () => void; +} + +export default function AccountDetailsDialog({ account, onClose }: AccountDetailsDialogProps) { + const { t } = useTranslation(); + if (!account) return null; + + return createPortal( +
+ {/* Draggable Top Region */} +
+ +
+ {/* Header */} +
+
+

{t('accounts.details.title')}

+
+ {account.email} +
+
+ +
+ + {/* Content */} +
+ {account.quota?.models?.map((model: ModelQuota) => ( +
+
+ + {model.name} + + = 50 ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400' : + model.percentage >= 20 ? 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : + 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400' + }`} + > + {model.percentage}% + +
+ + {/* Progress Bar */} +
+
= 50 ? 'bg-emerald-500' : + model.percentage >= 20 ? 'bg-orange-400' : + 'bg-red-500' + }`} + style={{ width: `${model.percentage}%` }} + >
+
+ +
+ + {t('accounts.reset_time')}: {formatDate(model.reset_time) || t('common.unknown')} +
+
+ )) || ( +
+ + {t('accounts.no_data')} +
+ )} +
+
+
+
, + document.body + ); +} diff --git a/src/components/accounts/AccountGrid.tsx b/src/components/accounts/AccountGrid.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f63ebe12fb5e7d4d9a6b2fb6a5699610a28a4dc7 --- /dev/null +++ b/src/components/accounts/AccountGrid.tsx @@ -0,0 +1,50 @@ +import { Account } from '../../types/account'; +import AccountCard from './AccountCard'; + +interface AccountGridProps { + accounts: Account[]; + selectedIds: Set; + refreshingIds: Set; + onToggleSelect: (id: string) => void; + currentAccountId: string | null; + switchingAccountId: string | null; + onSwitch: (accountId: string) => void; + onRefresh: (accountId: string) => void; + onViewDetails: (accountId: string) => void; + onExport: (accountId: string) => void; + onDelete: (accountId: string) => void; +} + +function AccountGrid({ accounts, selectedIds, refreshingIds, onToggleSelect, currentAccountId, switchingAccountId, onSwitch, onRefresh, onViewDetails, onExport, onDelete }: AccountGridProps) { + if (accounts.length === 0) { + return ( +
+

暂无账号

+

点击上方"添加账号"按钮添加第一个账号

+
+ ); + } + + return ( +
+ {accounts.map((account) => ( + onToggleSelect(account.id)} + isCurrent={account.id === currentAccountId} + isSwitching={account.id === switchingAccountId} + onSwitch={() => onSwitch(account.id)} + onRefresh={() => onRefresh(account.id)} + onViewDetails={() => onViewDetails(account.id)} + onExport={() => onExport(account.id)} + onDelete={() => onDelete(account.id)} + /> + ))} +
+ ); +} + +export default AccountGrid; diff --git a/src/components/accounts/AccountRow.tsx b/src/components/accounts/AccountRow.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5363e3d7eb58bfca0fc533d79d44d1df92e93f40 --- /dev/null +++ b/src/components/accounts/AccountRow.tsx @@ -0,0 +1,305 @@ +import { ArrowRightLeft, RefreshCw, Trash2, Download, Info, Lock, Ban, Diamond, Gem, Circle, Clock } from 'lucide-react'; +import { Account } from '../../types/account'; +import { getQuotaColor, formatTimeRemaining, getTimeRemainingColor } from '../../utils/format'; +import { cn } from '../../utils/cn'; +import { useTranslation } from 'react-i18next'; + +interface AccountRowProps { + account: Account; + selected: boolean; + onSelect: () => void; + isCurrent: boolean; + isRefreshing: boolean; + isSwitching?: boolean; + onSwitch: () => void; + onRefresh: () => void; + onViewDetails: () => void; + onExport: () => void; + onDelete: () => void; +} + +function AccountRow({ account, selected, onSelect, isCurrent, isRefreshing, isSwitching = false, onSwitch, onRefresh, onViewDetails, onExport, onDelete }: AccountRowProps) { + const { t } = useTranslation(); + const geminiProModel = account.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-pro-high'); + const geminiFlashModel = account.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-flash'); + const geminiImageModel = account.quota?.models.find(m => m.name.toLowerCase() === 'gemini-3-pro-image'); + const claudeModel = account.quota?.models.find(m => m.name.toLowerCase() === 'claude-sonnet-4-5-thinking'); + + // 颜色映射,避免动态类名被 Tailwind purge + const getColorClass = (percentage: number) => { + const color = getQuotaColor(percentage); + switch (color) { + case 'success': return 'bg-emerald-500'; + case 'warning': return 'bg-amber-500'; + case 'error': return 'bg-rose-500'; + default: return 'bg-gray-500'; + } + }; + + const getTimeColorClass = (resetTime: string | undefined) => { + const color = getTimeRemainingColor(resetTime); + switch (color) { + case 'success': return 'text-emerald-500 dark:text-emerald-400'; + case 'warning': return 'text-amber-500 dark:text-amber-400'; + default: return 'text-gray-400 dark:text-gray-500 opacity-60'; + } + }; + + return ( + + {/* 序号 */} + + onSelect()} + onClick={(e) => e.stopPropagation()} + /> + + + {/* 邮箱 */} + +
+ + {account.email} + + +
+ {isCurrent && ( + + {t('accounts.current').toUpperCase()} + + )} + + {account.quota?.is_forbidden && ( + + + {t('accounts.forbidden')} + + )} + + {/* 订阅类型徽章 */} + {account.quota?.subscription_tier && (() => { + const tier = account.quota.subscription_tier.toLowerCase(); + if (tier.includes('ultra')) { + return ( + + + ULTRA + + ); + } else if (tier.includes('pro')) { + return ( + + + PRO + + ); + } else { + return ( + + + FREE + + ); + } + })()} +
+
+ + + {/* 模型配额 */} + + {account.quota?.is_forbidden ? ( +
+ + {t('accounts.forbidden_msg')} +
+ ) : ( +
+ {/* Gemini Pro */} +
+ {geminiProModel && ( +
+ )} +
+ G3 Pro +
+ {geminiProModel?.reset_time ? ( + + + {formatTimeRemaining(geminiProModel.reset_time)} + + ) : ( + N/A + )} +
+ + {geminiProModel ? `${geminiProModel.percentage}%` : '-'} + +
+
+ + {/* Gemini Flash */} +
+ {geminiFlashModel && ( +
+ )} +
+ G3 Flash +
+ {geminiFlashModel?.reset_time ? ( + + + {formatTimeRemaining(geminiFlashModel.reset_time)} + + ) : ( + N/A + )} +
+ + {geminiFlashModel ? `${geminiFlashModel.percentage}%` : '-'} + +
+
+ + {/* Gemini Image */} +
+ {geminiImageModel && ( +
+ )} +
+ G3 Image +
+ {geminiImageModel?.reset_time ? ( + + + {formatTimeRemaining(geminiImageModel.reset_time)} + + ) : ( + N/A + )} +
+ + {geminiImageModel ? `${geminiImageModel.percentage}%` : '-'} + +
+
+ + {/* Claude */} +
+ {claudeModel && ( +
+ )} +
+ Claude 4.5 +
+ {claudeModel?.reset_time ? ( + + + {formatTimeRemaining(claudeModel.reset_time)} + + ) : ( + N/A + )} +
+ + {claudeModel ? `${claudeModel.percentage}%` : '-'} + +
+
+
+ )} + + + {/* 最后使用 */} + +
+ + {new Date(account.last_used * 1000).toLocaleDateString()} + + + {new Date(account.last_used * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+ + + {/* 操作 */} + +
+ + + + + +
+ + + ); +} + +export default AccountRow; diff --git a/src/components/accounts/AccountTable.tsx b/src/components/accounts/AccountTable.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb4c79f9562f86f5157281cc0cc2e750a7dafea3 --- /dev/null +++ b/src/components/accounts/AccountTable.tsx @@ -0,0 +1,74 @@ +import { Account } from '../../types/account'; +import AccountRow from './AccountRow'; +import { useTranslation } from 'react-i18next'; + +interface AccountTableProps { + accounts: Account[]; + selectedIds: Set; + refreshingIds: Set; + onToggleSelect: (id: string) => void; + onToggleAll: () => void; + currentAccountId: string | null; + switchingAccountId: string | null; + onSwitch: (accountId: string) => void; + onRefresh: (accountId: string) => void; + onViewDetails: (accountId: string) => void; + onExport: (accountId: string) => void; + onDelete: (accountId: string) => void; +} + +function AccountTable({ accounts, selectedIds, refreshingIds, onToggleSelect, onToggleAll, currentAccountId, switchingAccountId, onSwitch, onRefresh, onViewDetails, onExport, onDelete }: AccountTableProps) { + const { t } = useTranslation(); + + if (accounts.length === 0) { + return ( +
+

{t('accounts.empty.title')}

+

{t('accounts.empty.desc')}

+
+ ); + } + + return ( +
+ + + + + + + + + + + + {accounts.map((account) => ( + onToggleSelect(account.id)} + isCurrent={account.id === currentAccountId} + isSwitching={account.id === switchingAccountId} + onSwitch={() => onSwitch(account.id)} + onRefresh={() => onRefresh(account.id)} + onViewDetails={() => onViewDetails(account.id)} + onExport={() => onExport(account.id)} + onDelete={() => onDelete(account.id)} + /> + ))} + +
+ 0 && selectedIds.size === accounts.length} + onChange={onToggleAll} + /> + {t('accounts.table.email')}{t('accounts.table.quota')}{t('accounts.table.last_used')}{t('accounts.table.actions')}
+
+ ); +} + +export default AccountTable; diff --git a/src/components/accounts/AddAccountDialog.tsx b/src/components/accounts/AddAccountDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b869c939161ec562c3523d30804c851b98794a8 --- /dev/null +++ b/src/components/accounts/AddAccountDialog.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Plus, Loader2, CheckCircle2, XCircle, Key } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface AddAccountDialogProps { + onAdd: (email: string, refreshToken: string) => Promise; +} + +type Status = 'idle' | 'loading' | 'success' | 'error'; + +function AddAccountDialog({ onAdd }: AddAccountDialogProps) { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [refreshToken, setRefreshToken] = useState(''); + + // UI State + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(''); + + const resetState = () => { + setStatus('idle'); + setMessage(''); + setRefreshToken(''); + }; + + const handleSubmit = async () => { + if (!refreshToken) { + setStatus('error'); + setMessage(t('accounts.add.token.error_token')); + return; + } + + setStatus('loading'); + + // 1. 尝试解析输入 + let tokens: string[] = []; + const input = refreshToken.trim(); + + try { + // 尝试解析为 JSON + if (input.startsWith('[') && input.endsWith(']')) { + const parsed = JSON.parse(input); + if (Array.isArray(parsed)) { + tokens = parsed + .map((item: any) => item.refresh_token) + .filter((t: any) => typeof t === 'string' && t.startsWith('1//')); + } + } + } catch (e) { + // JSON 解析失败,忽略 + console.debug('JSON parse failed, falling back to regex', e); + } + + // 2. 如果 JSON 解析没有结果,尝试正则提取 (或者输入不是 JSON) + if (tokens.length === 0) { + const regex = /1\/\/[a-zA-Z0-9_\-]+/g; + const matches = input.match(regex); + if (matches) { + tokens = matches; + } + } + + // 去重 + tokens = [...new Set(tokens)]; + + if (tokens.length === 0) { + setStatus('error'); + setMessage(t('accounts.add.token.error_token')); + return; + } + + // 3. 批量添加 + let successCount = 0; + let failCount = 0; + + for (let i = 0; i < tokens.length; i++) { + const currentToken = tokens[i]; + setMessage(t('accounts.add.token.batch_progress', { current: i + 1, total: tokens.length })); + + try { + await onAdd("", currentToken); + successCount++; + } catch (error) { + console.error(`Failed to add token ${i + 1}:`, error); + failCount++; + } + // 稍微延迟一下,避免太快 + await new Promise(r => setTimeout(r, 100)); + } + + // 4. 结果反馈 + if (successCount === tokens.length) { + setStatus('success'); + setMessage(t('accounts.add.token.batch_success', { count: successCount })); + setTimeout(() => { + setIsOpen(false); + resetState(); + }, 1500); + } else if (successCount > 0) { + // 部分成功 + setStatus('success'); + setMessage(t('accounts.add.token.batch_partial', { success: successCount, fail: failCount })); + } else { + // 全部失败 + setStatus('error'); + setMessage(t('accounts.add.token.batch_fail')); + } + }; + + // 状态提示组件 + const StatusAlert = () => { + if (status === 'idle' || !message) return null; + + const styles = { + loading: 'alert-info', + success: 'alert-success', + error: 'alert-error' + }; + + const icons = { + loading: , + success: , + error: + }; + + return ( +
+ {icons[status]} + {message} +
+ ); + }; + + return ( + <> + + + {isOpen && createPortal( + +
+

{t('accounts.add.title')}

+ + {/* 状态提示区 */} + + +
+ {/* Token 图标和说明 */} +
+
+ +
+
+

+ {t('accounts.add.token.label')} +

+

+ {t('accounts.add.token.cloud_hint') || 'Enter your Refresh Token to add an account'} +

+
+
+ + {/* Token 输入区 */} +
+