[
  {
    "path": ".dockerignore",
    "content": "# Git files\n.git\n.gitignore\n\n# Docker files\nDockerfile\n.dockerignore\ndocker-compose.yml\nDockerfile.huggingface\n\n# Node dependencies (install these inside the container)\nnode_modules\n\n# Environment variables\n.env\n.env.*\n.dev.vars\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Coverage directory\ncoverage\n*.lcov\n.nyc_output\n\n# Build artifacts and caches\nbuild/\ndist/\n.cache/\n.parcel-cache/\n*.tsbuildinfo\n.npm/\n.eslintcache\n.stylelintcache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n.node_repl_history\n*.tgz\n.yarn-integrity\n.next/\nout/\n.nuxt/\n.vuepress/dist\n.temp/\n.docusaurus/\n.serverless/\n.fusebox/\n.dynamodb/\n.tern-port\n.vscode-test/\n\n# Yarn PnP files\n.yarn/\n.pnp.*\n\n# Wrangler project files (assuming they are not needed in this specific image)\nwrangler.toml\n.wrangler/\nworker/\n\n# Documentation and License (optional, remove if needed in image)\nREADME.md\nREADME_zh.md\nLICENSE\nget-gemhub.sh\ndoc/\ndata/"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Docker Image CI for Hugging Face\n\non:\n  push:\n    branches: [ \"main\" ]\n  workflow_dispatch:\n\njobs:\n  build_and_push:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Convert owner to lowercase\n        id: string\n        run: echo \"REPO_OWNER_LC=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')\" >> $GITHUB_ENV\n\n      - name: Convert repo name to lowercase\n        id: repo_name\n        run: echo \"REPO_NAME_LC=$(echo ${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]')\" >> $GITHUB_ENV\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ env.REPO_OWNER_LC }}/${{ env.REPO_NAME_LC }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile.huggingface\n          push: true\n          tags: ghcr.io/${{ env.REPO_OWNER_LC }}/${{ env.REPO_NAME_LC }}:latest\n          labels: ${{ steps.meta.outputs.labels }}"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\n\nlogs\n_.log\nnpm-debug.log_\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\n\nreport.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json\n\n# Runtime data\n\npids\n_.pid\n_.seed\n\\*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\n\nlib-cov\n\n# Coverage directory used by tools like istanbul\n\ncoverage\n\\*.lcov\n\n# nyc test coverage\n\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n\n.grunt\n\n# Bower dependency directory (https://bower.io/)\n\nbower_components\n\n# node-waf configuration\n\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\n\nbuild/Release\n\n# Dependency directories\n\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\n\nweb_modules/\n\n# TypeScript cache\n\n\\*.tsbuildinfo\n\n# Optional npm cache directory\n\n.npm\n\n# Optional eslint cache\n\n.eslintcache\n\n# Optional stylelint cache\n\n.stylelintcache\n\n# Microbundle cache\n\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n\n.node_repl_history\n\n# Output of 'npm pack'\n\n\\*.tgz\n\n# Yarn Integrity file\n\n.yarn-integrity\n\n# dotenv environment variable files\n\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# parcel-bundler cache (https://parceljs.org/)\n\n.cache\n.parcel-cache\n\n# Next.js build output\n\n.next\nout\n\n# Nuxt.js build / generate output\n\n.nuxt\ndist\n\n# Gatsby files\n\n.cache/\n\n# Comment in the public line in if your project uses Gatsby and not Next.js\n\n# https://nextjs.org/blog/next-9-1#public-directory-support\n\n# public\n\n# vuepress build output\n\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n\n.temp\n.cache\n\n# Docusaurus cache and generated files\n\n.docusaurus\n\n# Serverless directories\n\n.serverless/\n\n# FuseBox cache\n\n.fusebox/\n\n# DynamoDB Local files\n\n.dynamodb/\n\n# TernJS port file\n\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n\n.vscode-test\n\n# yarn v2\n\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.\\*\n\n# wrangler project\n\n.dev.vars\n.wrangler/\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:lts-slim\nWORKDIR /app\nCOPY package*.json ./\nRUN npm install --only=production\nCOPY . .\nEXPOSE 3000\nCMD [ \"npm\", \"start\" ]\n"
  },
  {
    "path": "Dockerfile.huggingface",
    "content": "FROM dreamhartley705/jimihub:latest\nENV HUGGING_FACE=1\nENV PORT=7860\nUSER root\nRUN mkdir -p /home/user/data && \\\n    chmod 777 /home/user/data && \\\n    chown -R node:node /home/user/data\nUSER node\nEXPOSE 7860\nCMD [ \"npm\", \"start\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "Creative Commons Attribution-NonCommercial 4.0 International Public License\n\nBy exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License (\"Public License\"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.\n\nSection 1 – Definitions.\n\na. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.\n\nb. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.\n\nc. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.\n\nd. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.\n\ne. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.\n\nf. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.\n\ng. Licensor means the individual(s) or entity(ies) granting rights under this Public License.\n\nh. NonCommercial means not primarily intended for or directed towards commercial advantage or monetary compensation. For purposes of this Public License, the exchange of the Licensed Material for other material subject to Copyright and Similar Rights by digital file-sharing or similar means is NonCommercial provided there is no payment of monetary compensation in connection with the exchange.\n\ni. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.\n\nj. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.\n\nk. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.\n\nSection 2 – Scope.\n\na. License grant.\n\n1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:\n\nA. reproduce and Share the Licensed Material, in whole or in part; and\n\nB. produce, reproduce, and Share Adapted Material.\n\n2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.\n\n3. Term. The term of this Public License is specified in Section 6(a).\n\n4. Media and formats; technical modifications. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Public License does not violate this Section 2(a)(4).\n\n5. Downstream recipients.\n\nA. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.\n\nB. Additional terms – Licensed Material. No downstream recipient of the Licensed Material may have any terms imposed on their use of the Licensed Material that restrict the terms of this Public License or the exercise of the Licensed Rights granted under this Public License.\n\nC. Offer from the Licensor – Adapted Material. Every recipient of Adapted Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License on the Adapted Material.\n\nD. Additional terms – Adapted Material. No downstream recipient of Adapted Material may have any terms imposed on their use of the Adapted Material that restrict the terms of this Public License or the exercise of the Licensed Rights granted under this Public License.\n\nb. Other rights.\n\n1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.\n\n2. Patent and trademark rights are not licensed under this Public License.\n\n3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme.\n\nSection 3 – License Conditions.\n\nYour exercise of the Licensed Rights is expressly made subject to the following conditions.\n\na. Attribution.\n\n1. If You Share the Licensed Material (including in modified form), You must:\n\nA. retain the following if it is supplied by the Licensor with the Licensed Material:\n\ni. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor;\n\nii. a copyright notice;\n\niii. a notice that refers to this Public License;\n\niv. a notice that refers to the disclaimer of warranties;\n\nv. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;\n\nB. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and\n\nC. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.\n\n2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.\n\n3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.\n\nb. NonCommercial. You may not exercise any of the Licensed Rights for commercial purposes.\n\nc. No additional restrictions. You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.\n\nSection 4 – Sui Generis Database Rights.\n\nWhere the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material, the Licensor waives to the fullest extent permitted by law the Licensed Rights, to the extent necessary to allow You to use, reproduce, and Share the Licensed Material including by including in Your adaptations any contents of the database.\n\nSection 5 – Disclaimer of Warranties and Limitation of Liability.\n\na. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, either express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable.\n\nb. To the extent possible, in no event will the Licensor be liable to You on any legal theory for any damages arising out of or in connection with the Licensed Material or the use or other dealings with the Licensed Material, including any direct, indirect, special, incidental, consequential, punitive, exemplary, or other damages, including loss of profits, data, use, goodwill, or other intangible losses, even if the Licensor has been advised of the possibility of such damages.\n\nSection 6 – Term and Termination.\n\na. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.\n\nb. Where Your rights under this Public License terminate, they will not be reinstated.\n\nc. Subject to the above, the license is perpetual (for the duration of the applicable rights).\n\nd. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.\n\nSection 7 – Other Terms and Conditions.\n\na. The Licensor and You agree that this Public License constitutes the entire agreement regarding the Licensed Material and supersedes all prior agreements and understandings, whether oral or written.\n\nb. The Licensor waives any right to collect royalties from You for the Licensed Rights licensed under this Public License.\n\nc. If any provision of this Public License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remaining provisions of this Public License, and such provision shall be reformed to the minimum extent necessary to make it valid and enforceable.\n\nd. No warranties are given. The license may not give You all of the permissions necessary for Your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how You use the Licensed Material.\n\ne. No trademark rights are granted.\n\nSection 8 – Interpretation.\n\na. For the avoidance of doubt, this Public License is not intended to reduce, limit, or restrict any uses free from copyright or rights arising from limitations or exceptions that are provided for in applicable copyright law.\n\nb. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to be enforceable.\n\nc. No warranties or conditions shall be construed to limit the scope of rights granted under this Public License.\n\nd. No interpretation of this Public License shall be used to justify any infringement of copyright or rights under this Public License.\n\nFor more information, please visit https://creativecommons.org/licenses/by-nc/4.0/legalcode\n"
  },
  {
    "path": "README.md",
    "content": "# JimiHub\n\n> **本项目遵循CC BY-NC 4.0协议，禁止任何形式的商业倒卖行为。**  \nThis project is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0).  \nCommercial resale or any form of commercial use is prohibited.\n\n[**中文介绍**](./README_zh.md \"Chinese Readme\") <br><br>\n[***详细部署与使用文档(新手看这里)***](./doc/项目介绍.md \"项目介绍\") <br><br>\n## Introduction\n\n`JimiHub` is a proxy service. It forwards requests formatted for the OpenAI API to the Google Gemini Pro API, allowing applications developed for OpenAI to seamlessly switch to or leverage the capabilities of Gemini models.\n\n## Features\n\n*   **OpenAI to Gemini Proxy**: Seamlessly translates OpenAI Chat API requests into Gemini Pro API requests.\n*   **Multi-API Key Rotation**: Supports configuring multiple Gemini API keys and automatically rotates through them to distribute request load and circumvent rate limits.\n*   **Quota and Usage Management**: Monitor the usage of each Gemini API key through an intuitive management interface.\n*   **Key Management**: Centrally manage multiple Gemini API keys and Worker API keys (used to access this proxy service) within the management panel.\n*   **Model Configuration**: Define and manage the Gemini models supported by this proxy in the management panel.\n*   **Intuitive Management Interface**: Provides a Web UI (`/login` or `/admin`) to view API usage statistics and configure settings.\n*   **One-Click Deployment**: Supports quick deployment to the Cloudflare Workers platform via the \"Deploy to Cloudflare\" button.\n*   **GitHub Actions Automatic Deployment**: After forking the repository, enables automatic deployment via GitHub Actions upon code push.\n*   **GitHub Database Sync**: Leverages GitHub repositories for automatic database synchronization.\n\n## Hugging Face Space Deployment\n\nThis deployment method utilizes Hugging Face Space's Docker environment and **requires enabling GitHub sync** for data persistence.\n\n1. **Prepare GitHub Repository and PAT**:\n   \n   * You need **your own** GitHub repository to store synchronized data. A private repository is recommended.\n   * Create a GitHub Personal Access Token (PAT) with the `repo` permission scope. **Keep this token secure**.\n\n2. **Create a Hugging Face Space**:\n   \n   * Visit Hugging Face and create a new Space.\n   * Select \"Docker\" as the Space SDK.\n   * Choose \"Use existing Dockerfile from repository\".\n\n3. **Configure Space Secrets**:\n   \n   * Go to your Space's \"Settings\" -> \"Repository secrets\".\n   * Add the following Secrets:\n     * `ADMIN_PASSWORD`: Set a login password for the admin panel.\n     * `SESSION_SECRET_KEY`: Set a long, random session key.\n     * `GITHUB_PROJECT`: Enter **your own** GitHub repository path in the format `your-username/your-repo-name`.\n     * `GITHUB_PROJECT_PAT`: Enter your GitHub PAT created earlier.\n     * `GITHUB_ENCRYPT_KEY`: Set an encryption key for synced data, **must be at least 32 characters long**.\n\n4. **Create Dockerfile**:\n   \n   * In your Hugging Face Space's \"Files\" tab, click \"Add file\" -> \"Create new file\".\n   * Set the filename to `Dockerfile`.\n   * Paste the following content into the file:\n     ```dockerfile\n     FROM dreamhartley705/jimihub:huggingface\n     ```\n   * Click \"Commit new file\".\n\n5. **Launch and Access**:\n   \n   * Hugging Face Space will automatically build and start the application using this `Dockerfile`.\n   * Once launched, the app will connect to your GitHub repository for data syncing using your configured Secrets.\n   * You can access the admin panel (`/login` or `/admin`) and API (`/v1`) via the URL provided by the Space.\n\n## Local Node.js Deployment\n\nThis method is suitable for local development and testing.\n\n1. **Clone the Repository**:\n    \n    ```bash\n    git clone https://github.com/dreamhartley/gemini-proxy-panel.git\n    cd gemini-proxy-panel\n    ```\n\n2. **Install Dependencies**:\n    \n    ```bash\n    npm install\n    ```\n\n3. **Configure Environment Variables**:\n    \n    * Copy the `.env.example` file to `.env`:\n        ```bash\n        cp .env.example .env\n        ```\n    * Edit the `.env` file, setting at minimum:\n        * `ADMIN_PASSWORD`: Set the admin panel login password.\n        * `SESSION_SECRET_KEY`: Set a long, random string for session security (e.g., generate with `openssl rand -base64 32`).\n        * `PORT` (optional): Default is 3000, change as needed.\n    * **(Optional) Configure GitHub Sync**: To sync data to a GitHub repository, set:\n        * `GITHUB_PROJECT`: Your GitHub repository path in format `username/repo-name`. **Note: This is your own repository for data backup, not this project's repository.**\n        * `GITHUB_PROJECT_PAT`: Your GitHub Personal Access Token with `repo` permission.\n        * `GITHUB_ENCRYPT_KEY`: An encryption key for syncing data, must be at least 32 characters long.\n\n4. **Start the Service**:\n    \n    ```bash\n    npm start\n    ```\n    \n    The service will run at `http://localhost:3000` (or your configured port).\n\n## Docker Deployment\n\nYou can quickly deploy using Docker or Docker Compose.\n\n### Method 1: Using `docker build` and `docker run`\n\n1. **Clone the Repository**:\n    \n    ```bash\n    git clone https://github.com/dreamhartley/JimiHub.git\n    cd JimiHub\n    ```\n\n2. **Configure Environment Variables**:\n    \n    * Copy the `.env.example` file to `.env`:\n        ```bash\n        cp .env.example .env\n        ```\n    * Edit the `.env` file, setting the necessary variables (`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`) and optional GitHub sync variables (`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`). **Note: The `PORT` variable typically doesn't need to be set in the `.env` file for Docker deployments, as port mapping is done in the `docker run` command.**\n\n3. **Build the Docker Image**:\n    \n    ```bash\n    docker build -t gemhub .\n    ```\n\n4. **Run the Docker Container**:\n    \n    ```bash\n    docker run -d --name gemhub \\\n      -p 3000:3000 \\\n      --env-file .env \\\n      -v ./data:/usr/src/app/data \\\n      gemhub\n    ```\n    \n    * `-d`: Run the container in the background.\n    * `--name gemhub`: Name for the container.\n    * `-p 3000:3000`: Map host port 3000 to container port 3000.\n    * `--env-file .env`: Load environment variables from the `.env` file.\n    * `-v ./data:/usr/src/app/data`: Mount the local `data` directory to the container for SQLite database persistence. Ensure the `data` directory exists locally.\n\n### Method 2: Using `docker-compose` (Recommended)\n\n1. **Clone the Repository**:\n   \n   ```bash\n   git clone https://github.com/dreamhartley/JimiHub.git\n   cd JimiHub\n   ```\n\n2. **Configure Environment Variables**:\n   \n   * Copy the `.env.example` file to `.env`:\n     ```bash\n     cp .env.example .env\n     ```\n   * Edit the `.env` file, setting the necessary variables (`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`) and optional GitHub sync variables (`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`).\n\n3. **Start the Service**:\n   \n   ```bash\n   docker-compose up -d\n   ```\n   \n   Docker Compose will automatically build the image (if needed), create and start the container, and handle port mapping, environment variables, and data volumes according to the `docker-compose.yml` file.\n\n\n## Usage\n\n### Management Panel\n\n1.  Access the `/login` or `/admin` path of your Worker URL (e.g., `https://your-worker-name.your-subdomain.workers.dev/login`).\n2.  Log in using the `ADMIN_PASSWORD` you set.\n3.  In the management panel, you can:\n    *   Add and manage your Gemini API keys.\n    *   Add and manage API keys used to access this Worker proxy (Worker API Keys).\n    *   Set global quotas for Pro and Flash series models.\n    *   View usage statistics for each Gemini API key.\n    *   Configure supported Gemini models.\n\n### API Proxy\n\n1.  Point the API endpoint of your application (originally configured to call the OpenAI API) to your deployed Worker URL (e.g., `https://your-worker-name.your-subdomain.workers.dev/v1`).\n2.  Ensure that your application includes valid authentication information when sending requests. This is usually done by carrying the \"Worker API Key\" configured in the management panel in the `Authorization` request header:\n    ```\n    Authorization: Bearer <your_worker_api_key>\n    ```\n3.  Send requests compatible with the OpenAI Chat Completions API. The Worker will convert them into Gemini API requests and return the formatted response.\n\n## Configuration Overview\n\n## Configuration Overview\n\n### Local Node.js / Docker / Hugging Face Deployments\n\nThese deployment methods configure environment variables through the `.env` file or Secrets (Hugging Face).\n\n* **Core Environment Variables (Required)**:\n  * `ADMIN_PASSWORD`: Login password for the admin panel.\n  * `SESSION_SECRET_KEY`: Key for securing user sessions (use a long, random string).\n* **Optional Environment Variables**:\n  * `PORT`: (Local Node.js/Docker only) Port for the service to listen on, default is 3000. Hugging Face handles the port automatically.\n* **GitHub Sync Environment Variables (Optional, Required for Hugging Face)**:\n  * `GITHUB_PROJECT`: Path to **your own** GitHub repository for data syncing (format: `username/repo-name`).\n  * `GITHUB_PROJECT_PAT`: GitHub Personal Access Token with `repo` permission.\n  * `GITHUB_ENCRYPT_KEY`: Key for encrypting synced data (at least 32 characters).\n\n"
  },
  {
    "path": "README_zh.md",
    "content": "# JimiHub\n\n## 简介\n\n`JimiHub` 是一个代理服务。它可以将 OpenAI API 格式的请求转发给 Google Gemini Pro API，使得为 OpenAI 开发的应用能够无缝切换或利用 Gemini 模型的能力。\n\n## 功能\n\n* **OpenAI 到 Gemini 代理**: 无缝将 OpenAI Chat API 请求转换为 Gemini Pro API 请求。\n* **多 API Key 轮询**: 支持配置多个 Gemini API Key，并自动轮询使用，以分摊请求负载和规避速率限制。\n* **配额与用量管理**: 通过直观的管理界面监控每个 Gemini API Key 的使用情况。\n* **密钥管理**: 在管理面板中集中管理多个 Gemini API Key 和 Worker API Key（用于访问此代理服务）。\n* **模型配置**: 在管理面板中定义和管理此代理支持的 Gemini 模型。\n* **直观的管理界面**: 提供 Web UI (`/login` 或 `/admin`) 查看 API 使用统计和配置设置。\n* **一键部署**: 支持通过 \"Deploy to Cloudflare\" 按钮快速部署到 Cloudflare Workers 平台。\n* **GitHub Actions 自动部署**: Fork 仓库后，可通过 GitHub Actions 实现推送代码时自动部署。\n* **GitHub 同步数据库**: 利用GitHub仓库自动同步数据库\n\n## Hugging Face Space 部署\n\n此部署方式利用 Hugging Face Space 的 Docker 环境运行，并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n1. **准备 GitHub 仓库和 PAT**:\n   \n   * 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。\n   * 创建一个 GitHub Personal Access Token (PAT)，并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。\n2. **创建 Hugging Face Space**:\n   \n   * 访问 Hugging Face 并创建一个新的 Space。\n   * 选择 \"Docker\" 作为 Space SDK。\n   * 选择 \"Use existing Dockerfile from repository\"。\n3. **配置 Space Secrets**:\n   \n   * 进入你创建的 Space 的 \"Settings\" -> \"Repository secrets\"。\n   * 添加以下 Secrets：\n     * `ADMIN_PASSWORD`: 设置管理面板的登录密码。\n     * `SESSION_SECRET_KEY`: 设置一个长且随机的会话密钥。\n     * `GITHUB_PROJECT`: 填入你**自己的** GitHub 仓库路径，格式为 `your-username/your-repo-name`。\n     * `GITHUB_PROJECT_PAT`: 填入你创建的 GitHub PAT。\n     * `GITHUB_ENCRYPT_KEY`: 设置一个用于加密同步数据的密钥，**必须是 32 位或更长的字符串**。\n4. **创建 Dockerfile**:\n   \n   * 在你的 Hugging Face Space 的 \"Files\" 标签页中，点击 \"Add file\" -> \"Create new file\"。\n   * 将文件名设置为 `Dockerfile`。\n   * 将以下内容粘贴到文件中：\n     ```dockerfile\n     FROM dreamhartley705/jimihub:huggingface\n     ```\n   * 点击 \"Commit new file\"。\n5. **启动和访问**:\n   \n   * Hugging Face Space 会自动使用此 `Dockerfile` 构建并启动应用。\n   * 应用启动后，会使用你配置的 Secrets 连接到你的 GitHub 仓库进行数据同步。\n   * 你可以通过 Space 提供的 URL 访问管理面板 (`/login` 或 `/admin`) 和 API (`/v1`)。\n\n\n\n## 本地 Node.js 部署\n\n此方式适合本地开发和测试。\n\n1. **克隆仓库**:\n    \n    ```bash\n    git clone https://github.com/dreamhartley/JimiHub.git\n    cd JimiHub\n    ```\n2. **安装依赖**:\n    \n    ```bash\n    npm install\n    ```\n3. **配置环境变量**:\n    \n    * 复制 `.env.example` 文件为 `.env`:\n        ```bash\n        cp .env.example .env\n        ```\n    * 编辑 `.env` 文件，至少设置以下变量：\n        * `ADMIN_PASSWORD`: 设置管理面板的登录密码。\n        * `SESSION_SECRET_KEY`: 设置一个长且随机的字符串用于会话安全（例如，使用 `openssl rand -base64 32` 生成）。\n        * `PORT` (可选): 默认是 3000，可以根据需要修改。\n    * **(可选) 配置 GitHub 同步**: 如果需要将数据同步到 GitHub 仓库，请配置以下变量：\n        * `GITHUB_PROJECT`: 你的 GitHub 仓库路径，格式为 `username/repo-name`。**注意：这是你自己的仓库，用于存储数据备份，并非本项目仓库。**\n        * `GITHUB_PROJECT_PAT`: 你的 GitHub Personal Access Token，需要 `repo` 权限。\n        * `GITHUB_ENCRYPT_KEY`: 用于加密同步数据的密钥，必须是 32 位或更长的字符串。\n4. **启动服务**:\n    \n    ```bash\n    npm start\n    ```\n    \n    服务将在 `http://localhost:3000` (或你配置的端口) 运行。\n\n## Docker 部署\n\n你可以使用 Docker 或 Docker Compose 快速部署。\n\n### 方式一：使用 `docker build` 和 `docker run`\n\n1. **克隆仓库**:\n    \n    ```bash\n    git clone https://github.com/dreamhartley/JimiHub.git\n    cd JimiHub\n    ```\n2. **配置环境变量**:\n    \n    * 复制 `.env.example` 文件为 `.env`:\n        ```bash\n        cp .env.example .env\n        ```\n    * 编辑 `.env` 文件，设置必要的变量（`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`）以及可选的 GitHub 同步变量（`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`）。**注意：`PORT` 变量在 Docker 部署中通常不需要在 `.env` 文件中设置，端口映射在 `docker run` 命令中完成。**\n3. **构建 Docker 镜像**:\n    \n    ```bash\n    docker build -t gemhub .\n    ```\n4. **运行 Docker 容器**:\n    \n    ```bash\n    docker run -d --name gemhub \\\n      -p 3000:3000 \\\n      --env-file .env \\\n      -v ./data:/usr/src/app/data \\\n      gemhub\n    ```\n    \n    * `-d`: 后台运行容器。\n    * `--name gemhub`: 给容器命名。\n    * `-p 3000:3000`: 将主机的 3000 端口映射到容器的 3000 端口。\n    * `--env-file .env`: 从 `.env` 文件加载环境变量。\n    * `-v ./data:/usr/src/app/data`: 将本地的 `data` 目录挂载到容器内，用于持久化 SQLite 数据库。请确保本地存在 `data` 目录。\n\n### 方式二：使用 `docker-compose` (推荐)\n\n1. **克隆仓库**:\n   \n   ```bash\n   git clone https://github.com/dreamhartley/JimiHub.git\n   cd JimiHub\n   ```\n2. **配置环境变量**:\n   \n   * 复制 `.env.example` 文件为 `.env`:\n     ```bash\n     cp .env.example .env\n     ```\n   * 编辑 `.env` 文件，设置必要的变量（`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`）以及可选的 GitHub 同步变量（`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`）。\n3. **启动服务**:\n   \n   ```bash\n   docker-compose up -d\n   ```\n   \n   Docker Compose 会自动构建镜像（如果需要）、创建并启动容器，并根据 `docker-compose.yml` 文件处理端口映射、环境变量和数据卷。\n\n\n\n## 使用\n\n### 管理面板\n\n1. 访问你的 URL 的 `/login` 或 `/admin` 路径 (例如: `https://your-worker-name.your-subdomain.workers.dev/login`)。\n2. 使用你设置的 `ADMIN_PASSWORD` 登录。\n3. 在管理面板中，你可以:\n   * 添加和管理你的 Gemini API Key。\n   * 添加和管理用于访问此 Worker 代理的 API Key (Worker API Keys)。\n   * 为 Pro 和 Flash 系列模型设置全局配额。\n   * 查看每个 Gemini API Key 的使用统计。\n   * 配置支持的 Gemini 模型。\n\n### API 代理\n\n1. 将你的应用程序的 API 端点（原本配置为调用 OpenAI API 的地址）指向你部署的 Worker URL (例如: `https://your-worker-name.your-subdomain.workers.dev/v1`)。\n2. 确保你的应用在发送请求时包含有效的身份验证信息。这通常通过在 `Authorization` 请求头中携带在管理面板配置的 \"Worker API Key\" 来完成：\n   ```\n   Authorization: Bearer <your_worker_api_key>\n   ```\n3. 发送与 OpenAI Chat Completions API 兼容的请求。Worker 会将其转换为 Gemini API 请求，并返回格式化的响应。\n\n## 配置概览\n\n### 本地 Node.js / Docker / Hugging Face 部署\n\n这些部署方式通过 `.env` 文件或 Secrets (Hugging Face) 配置环境变量。\n\n* **核心环境变量 (必须)**:\n  * `ADMIN_PASSWORD`: 管理面板的登录密码。\n  * `SESSION_SECRET_KEY`: 用于保护用户会话安全的密钥 (建议使用长随机字符串)。\n* **可选环境变量**:\n  * `PORT`: (仅本地 Node.js/Docker) 服务监听的端口，默认为 3000。Hugging Face 会自动处理端口。\n* **GitHub 同步环境变量 (可选, Hugging Face 必需)**:\n  * `GITHUB_PROJECT`: 用于数据同步的**你自己的** GitHub 仓库路径 (格式: `username/repo-name`)。\n  * `GITHUB_PROJECT_PAT`: 具有 `repo` 权限的 GitHub Personal Access Token。\n  * `GITHUB_ENCRYPT_KEY`: 用于加密同步数据的密钥 (至少 32 位)。\n\n"
  },
  {
    "path": "doc/Deploy/Colab/Colab部署.md",
    "content": "# Colab 部署\n\n此部署方式利用 Colab 的 Notebook 环境运行，并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n注意，由于 Colab 的特性，此部署方式无法做到持续运行，每次运行后退出网页，实例最长运行90分钟。但此方法拥有以下显著优点：\n\n* 无门槛，只要有 Google 帐号即可使用\n* 部署简单，通过笔记本一键部署运行\n* 最大化利用 GitHub 同步功能，实现数据的持久保存\n\n\n1. **准备 GitHub 仓库和 PAT (必须)**:\n   \n   * 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。\n   * 创建一个 GitHub Personal Access Token (PAT)，并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。\n   * 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)\n\n2. **保存 Colab 笔记本**:\n\n   * 点击[![Open In Colab](202507131855.svg)](https://colab.research.google.com/github/dreamhartley/JimiHub/blob/main/doc/Deploy/Colab/colab启动.ipynb)打开笔记本。\n   * 点击左上角的`复制到云端硬盘`，这将在您自己的 Google Drive 中创建一个副本。\n     ![](image/1.0.jpg)\n   * 页面将进行跳转，确认新的笔记本名称为`“colab启动.ipynb”的副本`，您可以自行修改名称。\n     ![](image/1.1.jpg)\n\n3. **填写环境变量**:\n\n   * 在您自己的笔记本中，来到`配置环境变量`代码单元，在表单中根据说明填写您的配置信息。\n     ![](image/2.0.jpg)\n   * 在表单中填写必填项，包括管理员密码、GitHub 项目路径和 PAT。\n   * 如果需要，可以填写可选项，包括加密密钥。\n   * 填写的配置信息会自动保存，下次启动时无需再次填写。\n\n4. **运行笔记本**:\n\n   * 确认所有配置无误后，点击`全部运行`按钮，即可启动面板。\n     ![](image/2.1.jpg)\n   * 在`启动面板`代码单元的输出中可以找到Cloudflare Tunnel的临时地址，点击即可访问后台UI。\n     ![](image/2.2.jpg)\n\n5. **持续运行与结束**:\n\n   * Colab 实例在未操作的情况下最长运行90分钟，您可以关闭网页(不要结束运行)，实例会继续运行。\n   * 如果需要保持运行，可以在保持网页开启的情况下最长运行12小时。\n   * 当需要结束运行时，点击右上角的`断开连接并删除运行时`。\n     ![](image/3.0.jpg)\n   * 填写的信息会自动保存，下次使用时访问[Google Colab](https://colab.research.google.com/)选择之前保存的笔记本，无须输入即可直接`全部运行`，每次连接时仅需要替换新的临时地址即可。\n\n6. **在后台中进行设置**\n\n   * 在后台UI中进行配置 Api 连接，详细请参考[配置API连接教程](../../Usage/配置API连接.md)。\n"
  },
  {
    "path": "doc/Deploy/Colab/colab启动.ipynb",
    "content": "{\n  \"cells\": [\n    {\n      \"cell_type\": \"code\",\n      \"execution_count\": null,\n      \"metadata\": {\n        \"cellView\": \"form\",\n        \"id\": \"Y3W1uluay548\"\n      },\n      \"outputs\": [],\n      \"source\": [\n        \"# @title 🔨 1. 安装依赖\\n\",\n        \"import subprocess\\n\",\n        \"import sys\\n\",\n        \"import os\\n\",\n        \"\\n\",\n        \"def run_cmd(cmd, cwd=None):\\n\",\n        \"    result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)\\n\",\n        \"    if result.returncode != 0:\\n\",\n        \"        print(f\\\"❌ 命令执行出错: {cmd}\\\")\\n\",\n        \"        print(result.stderr.decode('utf-8'))\\n\",\n        \"        sys.exit(1)\\n\",\n        \"\\n\",\n        \"print('⏳ 正在安装依赖...')\\n\",\n        \"\\n\",\n        \"try:\\n\",\n        \"    # 安装 Node.js 20\\n\",\n        \"    run_cmd('curl -fsSL https://deb.nodesource.com/setup_20.x | bash -')\\n\",\n        \"    run_cmd('apt-get install -y nodejs')\\n\",\n        \"\\n\",\n        \"    # 克隆 GitHub 项目\\n\",\n        \"    run_cmd('git clone https://github.com/dreamhartley/JimiHub.git')\\n\",\n        \"\\n\",\n        \"    # 进入项目目录\\n\",\n        \"    os.chdir('JimiHub')\\n\",\n        \"\\n\",\n        \"    # 使用 npm 安装项目依赖\\n\",\n        \"    run_cmd('npm install')\\n\",\n        \"\\n\",\n        \"    print('✅ 依赖安装完成！')\\n\",\n        \"except Exception as e:\\n\",\n        \"    print('❌ 安装过程中出现了错误：', e)\\n\"\n      ]\n    },\n    {\n      \"cell_type\": \"code\",\n      \"execution_count\": null,\n      \"metadata\": {\n        \"cellView\": \"form\",\n        \"id\": \"FxGtbacnzSZn\"\n      },\n      \"outputs\": [],\n      \"source\": [\n        \"# @title ⚙️ 2. 配置环境变量\\n\",\n        \"%cd /content/JimiHub\\n\",\n        \"# @markdown 请在下面的表单中填入您的配置信息。\\n\",\n        \"# @markdown ---\\n\",\n        \"\\n\",\n        \"# @markdown ### **必填项**\\n\",\n        \"# @markdown **1. 管理员密码 (`ADMIN_PASSWORD`)**\\n\",\n        \"# @markdown 后台管理面板的登录密码。\\n\",\n        \"admin_password = \\\"\\\"  #@param {type:\\\"string\\\"}\\n\",\n        \"\\n\",\n        \"# @markdown **2. GitHub 项目 (`GITHUB_PROJECT`)**\\n\",\n        \"# @markdown 您的 GitHub 项目路径，格式为 `用户名/仓库名`。\\n\",\n        \"github_project = \\\"\\\"  #@param {type:\\\"string\\\"}\\n\",\n        \"\\n\",\n        \"# @markdown **3. GitHub PAT (`GITHUB_PROJECT_PAT`)**\\n\",\n        \"# @markdown 用于访问 GitHub 仓库的个人访问令牌 (Personal Access Token)。请确保它具有读写仓库内容的权限。\\n\",\n        \"github_pat = \\\"\\\"  #@param {type:\\\"string\\\"}\\n\",\n        \"\\n\",\n        \"# @markdown ---\\n\",\n        \"# @markdown ### **可选项**\\n\",\n        \"# @markdown **4. 加密密钥 (`GITHUB_ENCRYPT_KEY`)**\\n\",\n        \"# @markdown （可选）一个32位长度的字符串，用于加密敏感数据，对于已加密的数据库必须使用和之前相同的密钥。如果留空，则不启用加密。\\n\",\n        \"github_encrypt_key = \\\"\\\"  #@param {type:\\\"string\\\"}\\n\",\n        \"\\n\",\n        \"\\n\",\n        \"# --- 后续逻辑 (无需修改) ---\\n\",\n        \"# 检查必填项是否已填写\\n\",\n        \"if not all([admin_password, github_project, github_pat]):\\n\",\n        \"    print(\\\"❌ 错误：管理员密码、GitHub 项目和 PAT 均为必填项，请填写后再运行！\\\")\\n\",\n        \"else:\\n\",\n        \"    # 使用从表单获取的变量创建 .env 文件内容\\n\",\n        \"    # .strip() 用于移除开头和结尾可能存在的空白\\n\",\n        \"    env_content = f\\\"\\\"\\\"\\n\",\n        \"ADMIN_PASSWORD={admin_password}\\n\",\n        \"GITHUB_PROJECT={github_project}\\n\",\n        \"GITHUB_PROJECT_PAT={github_pat}\\n\",\n        \"GITHUB_ENCRYPT_KEY={github_encrypt_key}\\n\",\n        \"\\\"\\\"\\\".strip()\\n\",\n        \"\\n\",\n        \"    # 将内容写入 .env 文件\\n\",\n        \"    with open(\\\".env\\\", \\\"w\\\") as f:\\n\",\n        \"        f.write(env_content)\\n\",\n        \"\\n\",\n        \"    print(\\\"✅ .env 文件已成功创建并写入以下内容：\\\")\\n\",\n        \"    # 打印文件内容以供核对\\n\",\n        \"    !cat .env\\n\",\n        \"\\n\"\n      ]\n    },\n    {\n      \"cell_type\": \"code\",\n      \"execution_count\": null,\n      \"metadata\": {\n        \"cellView\": \"form\",\n        \"id\": \"T3Wzz133zWEx\"\n      },\n      \"outputs\": [],\n      \"source\": [\n        \"# @title 🚀 3. 启动面板\\n\",\n        \"# 下载 Cloudflare Tunnel\\n\",\n        \"!wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -O cloudflared\\n\",\n        \"!chmod +x cloudflared\\n\",\n        \"\\n\",\n        \"import subprocess\\n\",\n        \"import time\\n\",\n        \"from IPython.display import display, HTML\\n\",\n        \"import re\\n\",\n        \"\\n\",\n        \"def start_cloudflare_tunnel(log_path='tunnel.log', retries=5, wait_sec=5):\\n\",\n        \"    \\\"\\\"\\\"启动 Cloudflare Tunnel 并返回访问 URL，失败时自动重试。\\\"\\\"\\\"\\n\",\n        \"    tunnel_cmd = './cloudflared tunnel --url http://127.0.0.1:3000 > {} 2>&1 &'.format(log_path)\\n\",\n        \"    for attempt in range(1, retries + 1):\\n\",\n        \"        # 启动隧道\\n\",\n        \"        subprocess.Popen(tunnel_cmd, shell=True)\\n\",\n        \"        print(f\\\"第 {attempt} 次尝试启动 Cloudflare Tunnel...\\\")\\n\",\n        \"        time.sleep(wait_sec)\\n\",\n        \"        # 提取 URL\\n\",\n        \"        tunnel_url = None\\n\",\n        \"        try:\\n\",\n        \"            with open(log_path, 'r', encoding='utf-8', errors='ignore') as f:\\n\",\n        \"                for line in f:\\n\",\n        \"                    match = re.search(r'https://[a-zA-Z0-9-]+\\\\.trycloudflare\\\\.com', line)\\n\",\n        \"                    if match:\\n\",\n        \"                        tunnel_url = match.group(0)\\n\",\n        \"                        break\\n\",\n        \"        except Exception as e:\\n\",\n        \"            print(f\\\"读取日志文件异常: {e}\\\")\\n\",\n        \"        if tunnel_url:\\n\",\n        \"            return tunnel_url\\n\",\n        \"        else:\\n\",\n        \"            print(\\\"未获取到 URL，准备重试...\\\")\\n\",\n        \"            # 杀掉可能遗留的 cloudflared 进程\\n\",\n        \"            subprocess.run(\\\"pkill -f cloudflared\\\", shell=True)\\n\",\n        \"            time.sleep(1)\\n\",\n        \"    return None\\n\",\n        \"\\n\",\n        \"tunnel_url = start_cloudflare_tunnel()\\n\",\n        \"\\n\",\n        \"if tunnel_url:\\n\",\n        \"    # 美化的 HTML 面板\\n\",\n        \"    panel_html = f\\\"\\\"\\\"\\n\",\n        \"    <div style=\\\"\\n\",\n        \"        border: 2px solid #2196F3;\\n\",\n        \"        border-radius: 14px;\\n\",\n        \"        background: linear-gradient(90deg,#f5f8fa 60%,#e3f2fd 100%);\\n\",\n        \"        padding: 24px 32px;\\n\",\n        \"        box-shadow: 0 2px 12px rgba(33,150,243,0.07);\\n\",\n        \"        margin: 18px 0;\\n\",\n        \"        font-family: 'Segoe UI',Arial,sans-serif;\\n\",\n        \"        font-size: 1.15em;\\n\",\n        \"        color: #222;\\n\",\n        \"        width: fit-content;\\n\",\n        \"        max-width: 90vw;\\n\",\n        \"      \\\">\\n\",\n        \"      <div style=\\\"font-size:1.3em;font-weight:bold;color:#1976D2;margin-bottom:8px;\\\">\\n\",\n        \"        ✅ Cloudflare Tunnel 已启动\\n\",\n        \"      </div>\\n\",\n        \"      <div>\\n\",\n        \"        <span style=\\\"font-weight:bold;\\\">访问地址：</span>\\n\",\n        \"        <a href=\\\"{tunnel_url}\\\" target=\\\"_blank\\\" style=\\\"color:#1976D2;text-decoration:underline;\\\">{tunnel_url}</a>\\n\",\n        \"      </div>\\n\",\n        \"      <div style=\\\"margin-top:6px;\\\">\\n\",\n        \"        <span style=\\\"font-weight:bold;\\\">API端点：</span>\\n\",\n        \"        <a href=\\\"{tunnel_url}/v1\\\" target=\\\"_blank\\\" style=\\\"color:#388E3C;text-decoration:underline;\\\">{tunnel_url}/v1</a>\\n\",\n        \"      </div>\\n\",\n        \"    </div>\\n\",\n        \"    \\\"\\\"\\\"\\n\",\n        \"    display(HTML(panel_html))\\n\",\n        \"else:\\n\",\n        \"    error_html = \\\"\\\"\\\"\\n\",\n        \"    <div style=\\\"\\n\",\n        \"        border:2px solid #E53935;\\n\",\n        \"        border-radius:10px;\\n\",\n        \"        background:#FFF3F3;\\n\",\n        \"        padding:18px 24px;\\n\",\n        \"        color:#B71C1C;\\n\",\n        \"        font-weight:bold;\\n\",\n        \"        font-size:1.15em;\\n\",\n        \"        width: fit-content;\\n\",\n        \"        max-width: 90vw;\\n\",\n        \"        margin: 18px 0;\\\">\\n\",\n        \"      ❌ <span>未能获取 Cloudflare Tunnel 的临时 URL，请检查 <code>tunnel.log</code> 或稍后重试。</span>\\n\",\n        \"    </div>\\n\",\n        \"    \\\"\\\"\\\"\\n\",\n        \"    display(HTML(error_html))\\n\",\n        \"\\n\",\n        \"# 启动 Node.js 项目\\n\",\n        \"print(\\\"\\\\n正在后台启动 Node.js 项目 (npm start)...\\\")\\n\",\n        \"!npm start\\n\"\n      ]\n    }\n  ],\n  \"metadata\": {\n    \"colab\": {\n      \"provenance\": []\n    },\n    \"kernelspec\": {\n      \"display_name\": \"Python 3\",\n      \"name\": \"python3\"\n    },\n    \"language_info\": {\n      \"name\": \"python\"\n    }\n  },\n  \"nbformat\": 4,\n  \"nbformat_minor\": 0\n}\n"
  },
  {
    "path": "doc/Deploy/GitHub/GitHub同步.md",
    "content": "# GitHub 同步功能\n\ngemini proxy panel 支持 GitHub 同步数据库功能，这个功能可以自动将数据库上传至您的私人 GitHub 仓库，并且在每次启动时自动下载最新的数据库，以保证数据的持久化存储。\n\n**注意：** 在不支持持久化数据的平台（例如 Hugging Face Space）部署时，必须启用此功能。\n\n## 创建数据库仓库\n\n1.  首先，来到 GitHub 后台，点击右上角的头像打开右侧边栏。\n\n    ![GitHub 后台界面](image/1.0.jpg)\n\n2.  点击 `Your repositories` 打开 repo 界面。\n\n    ![Your repositories 界面](image/1.1.jpg)\n\n3.  点击 `New` 新建一个仓库。\n\n    ![新建仓库按钮](image/1.2.jpg)\n\n4.  在 `Repository name` 处填写一个自定义名称，并且选择 `Private` 创建一个私人仓库，最后点击 `Create repository` 完成创建。\n\n    ![创建仓库表单](image/1.3.jpg)\n\n5.  在 repo 界面找到刚刚创建的仓库，点击打开，在 URL 栏复制后缀 `用户名/仓库名`，这将作为环境变量 `GITHUB_PROJECT` 使用。\n\n    ![仓库 URL 位置](image/1.4.jpg)\n\n## 创建 PAT 密钥\n\n1.  在 GitHub 后台，点击右上角的头像打开的右侧边栏中点击 `Settings`。\n\n    ![Settings 菜单](image/2.0.jpg)\n\n2.  在新打开的左侧边栏中的最下方点击 `Developer settings`。\n\n    ![Developer settings 选项](image/3.0.jpg)\n\n3.  选择 `Personal access tokens` 中的 `Tokens (classic)` 选项，并在右侧的页面中选择 `Generate new token` 并点击 `Generate new token (classic)`。\n\n    ![Personal access tokens 页面](image/4.0.jpg)\n\n4.  在新打开的页面中随意填写一个 `Note` 为 PAT 命名，并且在下方的权限选择区域中确保选中 `repo` 权限。可以设置让 PAT 不会过期。\n\n    ![PAT 权限设置](image/5.0.jpg)\n\n5.  点击 `Generate token` 创建 PAT 密钥。\n\n    ![Generate token 按钮](image/5.1.jpg)\n\n6.  在弹出的页面中记录 PAT 密钥，这将作为环境变量 `GITHUB_PROJECT_PAT` 使用。\n\n    ![生成的 PAT 密钥](image/5.2.jpg)\n\n## 启用 GitHub 同步功能\n\n在环境变量中正确配置上面步骤中获取的 `GITHUB_PROJECT`、`GITHUB_PROJECT_PAT` 即可启用 GitHub 同步功能。\n\n### 启用加密功能（可选）\n\n数据库加密功能将使用 AES-256-CBC 加密算法实现对上传的数据库进行加密，在不泄露密钥的情况下基本可保证数据的安全不被破解。\n\n在环境变量中添加变量 `GITHUB_ENCRYPT_KEY`，并配置一个最少 32 位的密钥以启用加密功能，建议使用[密码生成工具](https://1password.com/zh-cn/password-generator)生成。\n\n> **注意：** 如果您希望后续重置部署，或使用其他的部署时保持现有的数据，请在本地妥善保存以上使用到的环境变量。"
  },
  {
    "path": "doc/Deploy/HuggingFace/Hugging Face Space部署-fork说明.md",
    "content": "# Hugging Face Space部署出现问题的应对\n\n由于本项目在Hugging Face Space中部署较多，疑似被官方封禁，直接拉取项目镜像可能会导致部署失败或Space被停用，如果您在使用中遇到类似的问题，请参考以下的步骤Fork项目并创建自己的镜像：\n\n1. **创建GitHub仓库Fork**\n   - 创建Fork [dreamhartley/JimiHub](https://github.com/dreamhartley/JimiHub/fork)\n   - 确保使用自定义的仓库名称，**不要包含** `JimiHub`或`hajimi`等关键词\n\n2. **启用工作流**\n   - 在上方Actions标签栏，点击`I understand my workflows, go ahead and enable them`按钮\n     ![](image/8.0.jpg)\n\n3. **运行Docker镜像构建工作流**\n   - 在左侧边栏选择`Docker Image CI for Hugging Face`\n   - 在右侧点击`Run workflow`\n     ![](image/8.1.jpg)\n\n4. **获取镜像地址**\n   - 等待运行完成\n   - 在Fork的仓库页面，点击Packages中创建的镜像\n     ![](image/8.2.jpg)\n   - 复制镜像地址\n\n5. **创建Huggingface Space**\n   - 在Huggingface Space创建Dockerfile\n   - 内容填写：`FROM ghcr.io/GitHub用户名/Fork仓库名:latest`\n   - 替代原本教程中提供的镜像地址，其余步骤不变\n\n## 更新部署\n\n如果通过fork仓库创建镜像后部署在Huggingface Space的用户需要更新：\n\n1. 在fork的仓库页面点击`Sync fork`-->`Update branch`\n![](image/8.3.jpg)\n2. 等待Actions自动运行完成后会创建新的镜像\n3. 在Space页面点击`Settings`-->`Factory rebuild`即可"
  },
  {
    "path": "doc/Deploy/HuggingFace/Hugging Face Space部署.md",
    "content": "# Hugging Face Space 部署\n\n此部署方式利用 Hugging Face Space 的 Docker 环境运行，并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n> 本项目被Hugging Face标记，如果您在使用中存在问题，例如卡Building或者停止运行，请参考[Hugging Face部署出现问题的应对](Hugging%20Face%20Space部署-fork说明.md)\n\n1. **准备 GitHub 仓库和 PAT**:\n   \n   * 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。\n   * 创建一个 GitHub Personal Access Token (PAT)，并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。\n   * 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)\n2. **创建 Hugging Face Space**:\n   \n   * 访问 Hugging Face Space 页面，点击新建一个 Space。\\\n   ![](image/1.0.jpg)\n   * 在`Space name`处随意填写一个自定义名称，请不要包含`gemini proxy panel`或`hajimi`等关键词\\\n   ![](image/2.0.jpg)\n   * 选择 \"Docker\" 作为 Space SDK。\\\n   ![](image/2.1.jpg)\n   * 确保选择的免费的配置类型，并且设置空间为公开。最后点击`Create Space`。\\\n   ![](image/2.2.jpg)\n3. **配置 Space Secrets**:\n   \n   * 进入你创建的 Space 的 \"Settings\" -> \"Repository secrets\"。\\\n   ![](image/3.0.jpg)\n   ![](image/4.0.jpg)\n   * 添加以下 Secrets：\n     * `ADMIN_PASSWORD`: 设置管理面板的登录密码。\\\n     ![](image/4.4.jpg)\n     * `GITHUB_PROJECT`: 填入你**自己的** GitHub 仓库路径，格式为 `your-username/your-repo-name`。\\\n     ![](image/4.1.jpg)\n     * `GITHUB_PROJECT_PAT`: 填入你创建的 GitHub PAT。\\\n     ![](image/4.2.jpg)\n     * Secrets配置完成。\n     ![](image/5.0.jpg)\n\n4. **创建 Dockerfile**:\n   \n   > 本项目被Hugging Face标记，如果您在使用中存在问题，例如卡Building或者停止运行，请参考[Hugging Face部署出现问题的应对](Hugging%20Face%20Space部署-fork说明.md)\n\n   * 在 Hugging Face Space 的 \"Files\" 标签页中，点击 \"Add file\" -> \"Create new file\"。\\\n   ![](image/6.0.jpg)\n   * 将文件名设置为 `Dockerfile`。\n   * 将以下内容粘贴到文件中：\n     ```dockerfile\n     FROM dreamhartley705/jimihub:huggingface\n     ```\n     或Fork仓库创建的镜像地址\n     \n     ```dockerfile\n     FROM ghcr.io/GitHub用户名/Fork仓库名:latest\n     ```\n   * 点击 \"Commit new file\"。\n   ![](image/6.1.jpg)\n5. **启动和访问**:\n   \n   * Hugging Face Space 会自动使用此 `Dockerfile` 构建并启动应用。\n   * 应用启动后，会使用你配置的 Secrets 连接到你的 GitHub 仓库进行数据同步。\n   * 点击查看 Log 会自动显示后台UI地址。\\\n   ![](image/7.jpg)\n\n6. **在后台中进行设置**\n   \n   * 在后台UI中进行配置 Api 连接，详细请参考[配置API连接教程](../../Usage/配置API连接.md)\n"
  },
  {
    "path": "doc/Deploy/Koyeb/Koyeb部署.md",
    "content": "# Koyeb 部署\n\n此部署方式利用 Koyeb 的 Docker 环境运行，并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n1. **准备 GitHub 仓库和 PAT**(不可跳过):\n   \n   * 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。\n   * 创建一个 GitHub Personal Access Token (PAT)，并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。\n   * 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)\n   \n2. **在 Koyeb 创建并部署容器**:\n   \n   * 访问 [Koyeb注册页面](https://app.koyeb.com/auth/signup)。\n   * 选择一个方式创建账户，可以使用GitHub账号，或邮箱进行注册。\n     ![](image/1.0.jpg)\n   * 注册并验证账号后，在页面中输入一个自定义的组织名称，后续选项可以随意选择或跳过。\n     ![](image/1.1.jpg)\n   * 进入主界面后，在右侧选择Docker容器部署。\n     ![](image/2.0.jpg)\n   * 在Image选项填写`dreamhartley705/jimihub:latest`，点击下一步。\n     ![](image/2.1.jpg)\n   * 在配置选择界面，选择`CPU Eco`，并选择一个免费的容器类型，点击下一步。\n     ![](image/2.2.jpg)\n   * 来到详细配置页面，在`Edit variables and files`中配置环境变量，请确保填写`ADMIN_PASSWORD`,`GITHUB_PROJECT`,`GITHUB_PROJECT_PAT`这三个变量。`GITHUB_ENCRYPT_KEY`为可选的数据库加密选项，如果需要请自行填写。\n     ![](image/3.0.jpg)\n   * 选择`Exposed ports`选项，将端口修改为`3000`。\n     ![](image/3.1.jpg)\n   * 选择`Service name`选项，设置一个自定义的容器名称，请不要使用默认的名称。\n     ![](image/3.2.jpg)\n   * 配置完成后，点击`Deploy`即可创建容器。\n     ![](image/3.3.jpg)\n   * 在新页面中，等待容器创建完成，上方的`Public URL`为访问地址。\n     ![](image/4.0.jpg)\n\n3. **在后台中进行设置**\n\n   * 在后台UI中进行配置 Api 连接，详细请参考[配置API连接教程](../../Usage/配置API连接.md)。\n\n4. **Koyeb 容器保活(可选)**\n\n   * Koyeb 容器将在未使用时自动关闭，并且再次请求时自动启动，这会导致容器重启后第一次的请求时间大幅度延迟，如果您希望让容器保持运行，可以参考[配置Uptimerrobot](../Uptimerobot/配置Uptimerrobot.md)中的内容。\n"
  },
  {
    "path": "doc/Deploy/Local/本地部署.md",
    "content": "# 本地部署指南\n\n## Node.js 部署\n\n此方式适合本地开发和测试。\n\n1. **克隆仓库**:\n     \n     ```bash\n     git clone https://github.com/dreamhartley/JimiHub.git\n     cd JimiHub\n     ```\n2. **安装依赖**:\n     \n     ```bash\n     npm install\n     ```\n3. **配置环境变量**:\n     \n     * 复制 `.env.example` 文件为 `.env`:\n         ```bash\n         cp .env.example .env\n         ```\n     * 编辑 `.env` 文件，至少设置以下变量：\n         * `ADMIN_PASSWORD`: 设置管理面板的登录密码。\n         * `SESSION_SECRET_KEY`: 设置一个长且随机的字符串用于会话安全（例如，使用 `openssl rand -base64 32` 生成）。\n         * `PORT` (可选): 默认是 3000，可以根据需要修改。\n     * **(可选) 配置 GitHub 同步**: 如果需要将数据同步到 GitHub 仓库，请配置以下变量：\n         * `GITHUB_PROJECT`: 你的 GitHub 仓库路径，格式为 `username/repo-name`。**注意：这是你自己的仓库，用于存储数据备份，并非本项目仓库。**\n         * `GITHUB_PROJECT_PAT`: 你的 GitHub Personal Access Token，需要 `repo` 权限。\n         * `GITHUB_ENCRYPT_KEY`: 用于加密同步数据的密钥，必须是 32 位或更长的字符串。\n4. **启动服务**:\n     \n     ```bash\n     npm start\n     ```\n     \n     服务将在 `http://localhost:3000` (或你配置的端口) 运行。\n\n## Docker 部署\n\n你可以使用 Docker 或 Docker Compose 快速部署。\n\n### 方式一：使用 `docker build` 和 `docker run`\n\n1. **克隆仓库**:\n     \n     ```bash\n     git clone https://github.com/dreamhartley/JimiHub.git\n     cd JimiHub\n     ```\n2. **配置环境变量**:\n     \n     * 复制 `.env.example` 文件为 `.env`:\n         ```bash\n         cp .env.example .env\n         ```\n     * 编辑 `.env` 文件，设置必要的变量（`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`）以及可选的 GitHub 同步变量（`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`）。**注意：`PORT` 变量在 Docker 部署中通常不需要在 `.env` 文件中设置，端口映射在 `docker run` 命令中完成。**\n3. **构建 Docker 镜像**:\n     \n     ```bash\n     docker build -t jimihub .\n     ```\n4. **运行 Docker 容器**:\n     \n     ```bash\n     docker run -d --name jimihub \\\n       -p 3000:3000 \\\n       --env-file .env \\\n       -v ./data:/usr/src/app/data \\\n       jimihub\n     ```\n     \n     * `-d`: 后台运行容器。\n     * `--name gemini-proxy-panel`: 给容器命名。\n     * `-p 3000:3000`: 将主机的 3000 端口映射到容器的 3000 端口。\n     * `--env-file .env`: 从 `.env` 文件加载环境变量。\n     * `-v ./data:/usr/src/app/data`: 将本地的 `data` 目录挂载到容器内，用于持久化 SQLite 数据库。请确保本地存在 `data` 目录。\n\n### 方式二：使用 `docker-compose` (推荐)\n\n1. **克隆仓库**:\n    \n   ```bash\n   git clone https://github.com/dreamhartley/JimiHub.git\n   cd JimiHub\n   ```\n2. **配置环境变量**:\n    \n   * 复制 `.env.example` 文件为 `.env`:\n     ```bash\n     cp .env.example .env\n     ```\n   * 编辑 `.env` 文件，设置必要的变量（`ADMIN_PASSWORD`, `SESSION_SECRET_KEY`）以及可选的 GitHub 同步变量（`GITHUB_PROJECT`, `GITHUB_PROJECT_PAT`, `GITHUB_ENCRYPT_KEY`）。\n3. **启动服务**:\n    \n   ```bash\n   docker-compose up -d\n   ```\n    \n   Docker Compose 会自动构建镜像（如果需要）、创建并启动容器，并根据 `docker-compose.yml` 文件处理端口映射、环境变量和数据卷。"
  },
  {
    "path": "doc/Deploy/Render/Render部署.md",
    "content": "# Render 部署\n\n此部署方式利用 Render 的 Docker 环境运行，并**强制要求启用 GitHub 同步**功能以实现数据持久化。\n\n1. **准备 GitHub 仓库和 PAT**:\n   \n   * 你需要一个**自己的** GitHub 仓库来存储同步的数据。建议使用私有仓库。\n   * 创建一个 GitHub Personal Access Token (PAT)，并确保勾选了 `repo` 权限范围。**请妥善保管此 Token**。\n   * 具体操作步骤详见[GitHub配置同步教程](../GitHub/GitHub同步.md)\n\n2. **在 Render 创建并部署容器**\n\n   * 访问 [Render](https://render.com/)，点击右上角的`Get Started`按钮。\n     ![](image/1.0.jpg)\n   * 选择一个方式创建账户，可以使用邮箱或第三方账号，例如谷歌账号登陆。\n     ![](image/2.0.jpg)\n   * 在开始界面选择创建一个`Web Services`。\n     ![](image/3.0.jpg)\n   * 选择`Existing Image`，在`Image URL`中填写项目的镜像。\n     ```\n     dreamhartley705/jimihub:latest\n     ```\n     ![](image/4.0.jpg)\n   * 点击`Connect`，在`Name`中填写一个自定义容器的名称，这个名称将会是访问URL的一部分，**不建议使用默认**，请自行填写。在下方选择容器的区域，推荐使用美国(US)区域。\n     ![](image/4.1.jpg)\n   * 选择免费容器类型。\n     ![](image/4.2.jpg)\n   * 配置环境变量，请确保填写`ADMIN_PASSWORD`,`GITHUB_PROJECT`,`GITHUB_PROJECT_PAT`这三个变量。`GITHUB_ENCRYPT_KEY`为可选的数据库加密选项，如果需要请自行填写。\n     ![](image/4.3.jpg)<small>配置完成的示例</small>\n   * 点击`Deploy web service`即可创建容器。\n   * 在新页面中出现`Your service is live 🎉`表示部署成功，点击Log上方的URL即可访问容器地址。<br>\n   注意，如果在创建容器时未修改镜像名称，URL将会为默认名称后添加随机字符，建议修改为自定义名称。\n     ![](image/5.0.jpg)\n   * 后台地址为`https://xxx.onrender.com/admin`\n\n3. **在后台中进行设置**\n\n   * 在后台UI中进行配置 Api 连接，详细请参考[配置API连接教程](../../Usage/配置API连接.md)。\n\n4. **Render 容器保活(可选)**\n\n   * Render 容器将在未使用的15分钟后自动关闭，并且再次请求时自动启动，这会导致容器重启后第一次的请求时间大幅度延迟，如果您希望让容器保持运行，可以参考[配置Uptimerrobot](../Uptimerobot/配置Uptimerrobot.md)中的内容。\n\n    > ⚠️ 注意: Render 免费容器每月有750小时的免费额度，这意味着您在同一个 Render 账号中仅可部署一个持续运行的容器。超出免费额度的使用可能会被关停容器或要求付费。\n"
  },
  {
    "path": "doc/Deploy/Uptimerobot/配置Uptimerrobot.md",
    "content": "# 使用 Uptimerrobot 保活容器\n\n1. **注册 Uptimerrobot**\n   * 访问 [Uptimerrobot](https://uptimerobot.com/)，选择登陆或创建一个账号，创建账号时使用邮箱进行创建。\n     ![](image/1.0.jpg)\n     ![](image/1.1.jpg)\n\n2. **登陆并配置 Uptimerrobot**\n   * 注册并验证邮箱后可以登陆到 Uptimerrobot，如果是第一次登陆会直接提示配置监控。\n   * 在`Create your first monitor`中的`URL to monitor`处填写您的容器地址（主页即可）。点击`Create monitor`。\n    ![](image/2.0.jpg)\n   * 后续的步骤可以点击跳过(Skip)，最后点击`Nah, get me to dashboard already!`完成创建。\n    ![](image/2.1.jpg)\n\n## 您已经成功配置 Uptimerrobot\n\n免费套餐下 Uptimerrobot 会每 5 分钟访问一次配置的URL，您的容器已经实现了24小时在线运行。"
  },
  {
    "path": "doc/Usage/KEEPALIVE.md",
    "content": "# 功能介绍: KEEPALIVE模式\n\n## Docker/Hugging Face Space/Node.js\n\n在网页设置中开启`KEEPALIVE`开关，可以启用KEEPALIVE响应模式。\n\n启用KEEPALIVE模式后，使用流式请求到脚本，脚本会持续向客户端发送心跳响应以保持客户端的持续连接，防止客户端发生异常断开的情况。\n\n注意，当使用`KEEPALIVE`功能时，请确保使用的请求密钥(Worker Api Key)的安全设定为关闭(Disabled)，且在请求时使用流式响应。\n`KEEPALIVE`功能不会影响到非流式响应或启用了安全设定的流式响应。\n\n"
  },
  {
    "path": "doc/Usage/Vertex/Vertex代理配置.md",
    "content": "# Vertex 代理配置\n\n通过配置 `VERTEX` 环境变量，启用 Vertex 代理功能，添加使用 Vertex AI 平台的 Gemini 模型。\n\n---\n\n## 启用 Generative Language API\t\n\n * 如果您的项目还未启用 Generative Language API，需要先设置启用，已经启用可以跳过。\n * 在左侧边栏中选择`API 和服务`中的`已启用的API 和服务`。\n  ![](image/5.0.jpg)\n * 在打开的页面中点击`+ 启用API 和服务`。\n  ![](image/5.1.jpg)\n * 在搜索栏中输入搜索`Generative Language API`。\n  ![](image/5.2.jpg)\n * 选择启用 Generative Language API\n  ![](image/5.3.jpg)\n\n---\n\n## 创建服务账号\n\n * 在 [Google Cloud Platform](https://cloud.google.com/) 中登陆并激活账户。\n * 在左侧边栏中选择`IAM和管理`并点击`服务账号`。\n   ![](image/1.0.jpg)\n * 点击`创建服务账号`。\n   ![](image/2.0.jpg)\n * 在`服务账号详情`中随意填写一个`服务账号ID`。选择`创建并继续`。\n   ![](image/3.0.jpg)\n * 在角色中选择`Vertex Al Service Agent`。\n   ![](image/3.1.jpg)\n   ![](image/3.2.jpg)\n * 点击`完成`创建服务账号。\n   ![](image/3.3.jpg)\n\n---\n\n## 创建API凭证\n\n * 点击服务账号右侧的三点图标并选择`管理密钥`。\n  ![](image/4.0.jpg)\n  ![](image/4.1.jpg)\n * 在页面中点击`添加键`并选择`创建新密钥`。\n  ![](image/4.2.jpg)\n * 选择JSON格式并点击创建。\n  ![](image/4.3.jpg)\n\n---\n\n## 添加 API 凭证到环境变量\n\n在变量文件中添加一个`VERTEX`变量，并将下载的JSON格式变量完整地粘贴到变量中。\\\n参考格式\n```\nVERTEX={\n  \"type\": \"service_account\",\n  \"project_id\": \"XXXXXXXXXXX\",\n  \"private_key_id\": \"XXXXXXXXXXX\",\n  \"private_key\": \"-----BEGIN PRIVATE KEY-----\\ABCD\\n-----END PRIVATE KEY-----\\n\",\n  \"client_email\": \"XXXXXXXXXXX.iam.gserviceaccount.com\",\n  \"client_id\": \"XXXXXXXXXXX\",\n  \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n  \"token_uri\": \"https://oauth2.googleapis.com/token\",\n  \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n  \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/\n  \"universe_domain\": \"googleapis.com\"\n}\n```"
  },
  {
    "path": "doc/Usage/在客户端中使用.md",
    "content": ""
  },
  {
    "path": "doc/Usage/配置API连接.md",
    "content": "# 在管理UI中配置API连接\n\n## 添加 AI Studio 密钥\n\n * 在 `Gemini API Keys` 界面中的 `API Key Value` 中填写并添加 Gemini Api 密钥，可选择批量进行添加，批量添加时使用英文逗号 `,` 进行分隔，例如 `key1,key2,kye3`。在使用批量添加功能时请不要添加自定义名称功能。自定义名称可留空。\n ![](image/1.0.jpg)\n\n## 添加一个模型\n\n * 在 `Managed Models` 界面中添加想要使用的模型，当正确添了 Gemini Api 密钥后，刷新页面，脚本将自动获取当前可用的模型列表。\n ![](image/3.0.jpg)\n * 您可以选择在类别中切换模型的类别以匹配不同的额度，当然，大多数情况下脚本可用做到自动匹配。\n ![](image/3.1.jpg)\n * 当使用 Custom 类别的模型时，您可用输入一个基于模型的额度，Custom 类别中的每个模型的额度都会被独立管理。设置 `0` 或 `none` 表示无限额度。\n ![](image/3.1.1.jpg)\n\n## 添加一个请求 Api 密钥\n除了添加的 Gemini Api 外，您还需要添加用于请求到当前脚本的 Api 密钥。\n * 在 `Worker API Keys` 界面中添加密钥，点击 `Generate Random Key` 可用使用随机生成的密钥，或者您可以自行填写密钥，注意密钥中不要包含特殊字符。密钥的名称可自定义或留空。\n ![](image/2.0.jpg)\n * 您可用管理或分发多个 Api 密钥，该密钥为客户端请求时使用的密钥。\n\n## 测试 Gemini Api 密钥的可用性\n当正确配置 Gemini Api 密钥以及添加模型后，您可用点击密钥的显示卡片展开密钥使用情况，点击 `Test` 并选择一个测试模型即可快速测试密钥的可用性。\n![](image/1.1.jpg)\n\n## 管理安全设置\n使用本项目可用管理请求密钥在使用时的安全设定，当安全设定为 `Disabled` 时，将在转发 Gemini Api 请求时关闭默认的安全审查机制。您可以给不同的请求密钥设置不同的安全设定以适配不同的使用环境。当使用 `KEEPALIVE` 机制进行伪流式请求时需要关闭安全设定才会生效。\n![](image/2.1.jpg)\n\n## 管理配额\n在 `Managed Models` 界面点击 `Set Category Quotas`，可以设定 Pro 与 Flash 类别模型不同的每日额度。默认额度为 Pro 每天 50 次，Flash 每天 1500 次，请根据实际情况进行调整。\n![](image/3.1.2.jpg)\n![](image/3.1.3.jpg)\n\n## 添加 Vertex 配置\n本项目支持连接到Vertex AI平台的Gemini模型，Vertex配置的申请操作请参考：[Vertex代理配置](Vertex/Vertex代理配置.md)\n\n切换到Vertex标签后即可在网页中配置Vertex Api，支持两种方式连接到Vertex Api\n#### 服务账号 (JSON)\n- 选择`服务账号 (JSON)`并在输入框填写完整的 Google Cloud Service Account JSON 配置，点击`保存 Vertex 配置`即可保存服务账号信息。\n   ![](image/vertex-1.jpg)\n#### 快捷模式 (API Key)\n- 选择`快捷模式 (API Key)`并在输入框填写Express API Key，点击`保存 Vertex 配置`即可保存快捷模式密钥。\n   ![](image/vertex-2.jpg)\n#### 配置完成\n保存Vertex配置后，网页将会显示使用的Vertex配置信息，并且显示为`已启用`，此时连接到api端点即可使用带有`[v]`前缀的模型连接到Vertex Api。\n再次添加配置会覆盖当前的Vertex配置信息，点击`清除配置`将会删除保存的Vertex配置信息并停用Vertex代理功能。\n![](image/vertex-3.jpg)\n\n## 其他系统设置\n点击网页右上角的设置按钮，即可调整其他的一些系统功能\n![](image/setting.jpg)\n- **KEEPALIVE**：启用后可以使用保持连接方式处理请求，也被成为假流式，具体使用请参考[KEEPALIVE模式介绍](KEEPALIVE.md)\n- **联网搜索**：默认关闭，启用后将会在模型列表中添加`-search`后缀的模型，使用时将会允许模型通过互联网搜索信息，暂时仅对AI Studio的模型生效。\n- **最大重试次数**：请求失败后将会自动使用下一个有效的gemini api密钥重试请求，在此处修改允许重试的最大次数。\n"
  },
  {
    "path": "doc/项目介绍.md",
    "content": "# JimiHub\n\n## 项目简介\n\nJimiHub 是一款将 Gemini API 请求转换为 OpenAI API 格式的代理工具，支持多个项目轮询、密钥管理分发等功能。\n\n## 主要功能\n\n- **简洁的管理界面**\n- **支持流式/非流式响应，图片/文件上传，工具函数调用**\n- **多个 Gemini API Key 轮询**\n- **快速测试 Gemini API Key 可用性**\n- **简单便捷的模型管理**\n- **管理分发多个请求密钥**\n- **灵活且直观的额度管理及负载均衡**\n- **每日自动刷新额度**\n- **管理请求中的安全设置**\n- **可设置 GitHub 自动同步数据**\n- **开发中功能可能有变化**\n\n## 开始使用\n\n本项目支持多种部署方式，任何支持 Docker 容器以及 Node.js 的环境均可使用：\n\n- [Koyeb 部署](Deploy/Koyeb/Koyeb部署.md)<small>（免费，试用7天后需要绑定银行卡，ip不干净可能导致无法使用）</small>\n- [Colab 部署](Deploy/Colab/Colab部署.md) <small>（免费，门槛低，但无法持久运行，每次使用可快速部署）</small>\n- [Hugging Face Space 部署](Deploy/HuggingFace/Hugging%20Face%20Space部署.md) <small>（免费，目前抱脸封号严重，请自行尝试或使用其他部署方式）</small>\n- [Render 部署](Deploy/Render/Render部署.md)<small>（免费，部署时需要绑定银行卡，使用干净的ip地址可以跳过绑卡）</small>\n- [本地/VPS 部署](Deploy/Local/本地部署.md)\n- **VPS 一键部署脚本**\\\n  脚本将自动配置环境，并安装项目，根据提示设置管理密码即可。\\\n  适用于 Debian/Ubuntu/CentOS 系统：\n\n  ```bash\n  curl -fsSL https://raw.githubusercontent.com/dreamhartley/JimiHub/refs/heads/main/get-jimihub.sh -o get-jimihub.sh && sudo bash get-jimihub.sh\n  ```\n\n## 部署后配置\n\n- [管理界面的使用](Usage/配置API连接.md)\n\n## 可选功能\n\n- [KEEPALIVE](Usage/KEEPALIVE.md)\n- [Vertex 代理配置](Usage/Vertex/Vertex代理配置.md)\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '3.8' \nservices:\n  app:\n    image: dreamhartley705/jimihub:latest\n    container_name: jimihub\n    ports:\n      - \"3000:3000\"\n    env_file:\n      - .env\n    volumes:\n      - ./data:/app/data\n"
  },
  {
    "path": "get-jimihub.sh",
    "content": "#!/bin/bash\n\n# JimiHub管理脚本\n# 版本: 1.0\n\n# 定义颜色\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nBLUE='\\033[0;34m'\nNC='\\033[0m' # No Color\n\n# 定义路径\nINSTALL_DIR=\"/opt/jimihub\"\nSCRIPT_PATH=\"/usr/local/bin/jimihub\"\n\n# 显示ASCII图案\nshow_ascii() {\n    echo -e \"${BLUE}\"\n    cat << 'EOF'\n      ██╗██╗███╗   ███╗██╗██╗  ██╗██╗   ██╗██████╗ \n      ██║██║████╗ ████║██║██║  ██║██║   ██║██╔══██╗\n      ██║██║██╔████╔██║██║███████║██║   ██║██████╔╝\n ██   ██║██║██║╚██╔╝██║██║██╔══██║██║   ██║██╔══██╗\n ╚█████╔╝██║██║ ╚═╝ ██║██║██║  ██║╚██████╔╝██████╔╝\n  ╚════╝ ╚═╝╚═╝     ╚═╝╚═╝╚═╝  ╚═╝ ╚═════╝ ╚═════╝ \nEOF\n    echo -e \"${NC}\"\n}\n\n# 显示欢迎信息\nshow_welcome() {\n    clear\n    show_ascii\n    echo -e \"${GREEN}欢迎使用JimiHub管理脚本${NC}\"\n    echo -e \"${YELLOW}================================================${NC}\"\n    echo \"\"\n}\n\n# 检查是否为root用户\ncheck_root() {\n    if [[ $EUID -ne 0 ]]; then\n        echo -e \"${RED}错误：此脚本需要root权限运行${NC}\"\n        exit 1\n    fi\n}\n\n# 检查安装状态\ncheck_install_status() {\n    if [ -d \"$INSTALL_DIR\" ] && [ -f \"$INSTALL_DIR/docker-compose.yml\" ]; then\n        echo -e \"${GREEN}✓ JimiHub已安装${NC}\"\n        INSTALLED=true\n    else\n        echo -e \"${RED}✗ JimiHub未安装${NC}\"\n        INSTALLED=false\n    fi\n}\n\n# 检查容器运行状态\ncheck_container_status() {\n    if [ \"$INSTALLED\" = true ]; then\n        if docker ps | grep -q \"jimihub\"; then\n            echo -e \"${GREEN}✓ 容器正在运行${NC}\"\n            RUNNING=true\n            show_access_url\n        else\n            echo -e \"${YELLOW}✗ 容器未运行${NC}\"\n            RUNNING=false\n        fi\n    fi\n}\n\n# 显示访问URL\nshow_access_url() {\n    if [ -f \"$INSTALL_DIR/.env\" ]; then\n        PORT=$(grep \"PORT=\" \"$INSTALL_DIR/.env\" | cut -d'=' -f2)\n        if [ -z \"$PORT\" ]; then\n            PORT=3000\n        fi\n        \n        # 检查是否为本地访问\n        if grep -q \"127.0.0.1\" \"$INSTALL_DIR/docker-compose.yml\"; then\n            echo -e \"${BLUE}访问地址: http://127.0.0.1:$PORT${NC}\"\n        else\n            LOCAL_IP=$(hostname -I | awk '{print $1}')\n            echo -e \"${BLUE}访问地址: http://$LOCAL_IP:$PORT${NC}\"\n        fi\n    fi\n}\n\n# 获取外部IP\nget_external_ip() {\n    external_ip=$(curl -s https://ipv4.icanhazip.com/ || curl -s https://api.ipify.org)\n    if [ -z \"$external_ip\" ]; then\n        external_ip=$(hostname -I | awk '{print $1}')\n    fi\n    echo \"$external_ip\"\n}\n\n# 安装依赖\ninstall_dependencies() {\n    echo -e \"${YELLOW}正在安装依赖...${NC}\"\n    \n    # 更新包列表\n    if command -v apt-get &> /dev/null; then\n        apt-get update\n        apt-get install -y curl wget git\n    elif command -v yum &> /dev/null; then\n        yum install -y curl wget git\n    elif command -v dnf &> /dev/null; then\n        dnf install -y curl wget git\n    else\n        echo -e \"${RED}错误：无法识别的包管理器${NC}\"\n        exit 1\n    fi\n}\n\n# 检查Docker Compose命令\ncheck_docker_compose() {\n    if docker compose version &> /dev/null; then\n        DOCKER_COMPOSE_CMD=\"docker compose\"\n    elif command -v docker-compose &> /dev/null; then\n        DOCKER_COMPOSE_CMD=\"docker-compose\"\n    else\n        return 1\n    fi\n    return 0\n}\n\n# 安装Docker\ninstall_docker() {\n    echo -e \"${YELLOW}正在检查Docker安装状态...${NC}\"\n    \n    if ! command -v docker &> /dev/null; then\n        echo -e \"${YELLOW}Docker未安装，正在安装...${NC}\"\n        curl -fsSL https://get.docker.com -o get-docker.sh\n        sh get-docker.sh\n        systemctl start docker\n        systemctl enable docker\n        rm get-docker.sh\n        echo -e \"${GREEN}Docker安装完成${NC}\"\n    else\n        echo -e \"${GREEN}Docker已安装${NC}\"\n    fi\n    \n    # 检查Docker Compose\n    if ! check_docker_compose; then\n        echo -e \"${YELLOW}Docker Compose未安装，正在安装...${NC}\"\n        \n        # 尝试安装Docker Compose插件\n        if command -v apt-get &> /dev/null; then\n            apt-get update\n            apt-get install -y docker-compose-plugin\n        elif command -v yum &> /dev/null; then\n            yum install -y docker-compose-plugin\n        elif command -v dnf &> /dev/null; then\n            dnf install -y docker-compose-plugin\n        fi\n        \n        # 如果插件安装失败，则安装独立版本\n        if ! check_docker_compose; then\n            echo -e \"${YELLOW}正在安装独立版本的Docker Compose...${NC}\"\n            curl -L \"https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)\" -o /usr/local/bin/docker-compose\n            chmod +x /usr/local/bin/docker-compose\n        fi\n        \n        echo -e \"${GREEN}Docker Compose安装完成${NC}\"\n    else\n        echo -e \"${GREEN}Docker Compose已安装${NC}\"\n    fi\n}\n\n# 创建.env文件\ncreate_env_file() {\n    echo -e \"${YELLOW}正在创建配置文件...${NC}\"\n    \n    echo -n \"请输入管理密码: \"\n    read ADMIN_PASSWORD\n    echo \"\"\n    \n    cat > \"$INSTALL_DIR/.env\" << EOF\nADMIN_PASSWORD=$ADMIN_PASSWORD\nEOF\n    \n    echo -e \"使用默认端口3000? (y/n) [默认: y]: \"\n    read -r use_default_port\n    use_default_port=${use_default_port:-y}\n    \n    if [ \"$use_default_port\" = \"n\" ] || [ \"$use_default_port\" = \"N\" ]; then\n        echo -n \"请输入自定义端口: \"\n        read -r custom_port\n        echo \"PORT=$custom_port\" >> \"$INSTALL_DIR/.env\"\n        PORT=$custom_port\n    else\n        PORT=3000\n    fi\n}\n\n# 创建docker-compose.yml文件\ncreate_docker_compose() {\n    echo -e \"${YELLOW}正在创建Docker Compose文件...${NC}\"\n    \n    echo -e \"允许外部访问? (y/n) [默认: y]: \"\n    read -r allow_external\n    allow_external=${allow_external:-y}\n    \n    if [ \"$allow_external\" = \"n\" ] || [ \"$allow_external\" = \"N\" ]; then\n        PORTS_MAPPING=\"127.0.0.1:$PORT:$PORT\"\n    else\n        PORTS_MAPPING=\"$PORT:$PORT\"\n    fi\n    \n    cat > \"$INSTALL_DIR/docker-compose.yml\" << EOF\nversion: '3.8'\nservices:\n  app:\n    image: dreamhartley705/jimihub:latest\n    container_name: jimihub\n    ports:\n      - \"$PORTS_MAPPING\"\n    env_file:\n      - .env\n    volumes:\n      - ./data:/app/data\n    restart: unless-stopped\nEOF\n}\n\n# 安装JimiHub\ninstall_gemini_proxy_panel() {\n    echo -e \"${YELLOW}开始安装JimiHub...${NC}\"\n    \n    # 安装依赖\n    install_dependencies\n    \n    # 安装Docker\n    install_docker\n    \n    # 检查Docker Compose命令\n    if ! check_docker_compose; then\n        echo -e \"${RED}错误：Docker Compose未正确安装${NC}\"\n        return 1\n    fi\n    \n    # 创建安装目录\n    mkdir -p \"$INSTALL_DIR\"\n    cd \"$INSTALL_DIR\"\n    \n    # 创建.env文件\n    create_env_file\n    \n    # 创建docker-compose.yml文件\n    create_docker_compose\n    \n    # 启动容器\n    echo -e \"${YELLOW}正在启动容器...${NC}\"\n    $DOCKER_COMPOSE_CMD up -d\n    \n    # 等待容器启动\n    sleep 5\n    \n    if docker ps | grep -q \"jimihub\"; then\n        echo -e \"${GREEN}✓ 安装完成！${NC}\"\n        echo \"\"\n        \n        # 显示访问地址\n        if [ \"$allow_external\" = \"n\" ] || [ \"$allow_external\" = \"N\" ]; then\n            echo -e \"${BLUE}本地访问地址: http://127.0.0.1:$PORT${NC}\"\n        else\n            external_ip=$(get_external_ip)\n            echo -e \"${BLUE}访问地址: http://$external_ip:$PORT${NC}\"\n        fi\n        \n\n    else\n        echo -e \"${RED}✗ 安装失败，请检查错误信息${NC}\"\n        echo -e \"${YELLOW}容器日志:${NC}\"\n        $DOCKER_COMPOSE_CMD logs\n    fi\n}\n\n# 启动容器\nstart_container() {\n    if [ \"$INSTALLED\" = true ]; then\n        echo -e \"${YELLOW}正在启动容器...${NC}\"\n        cd \"$INSTALL_DIR\"\n        if check_docker_compose; then\n            $DOCKER_COMPOSE_CMD up -d\n            sleep 3\n            if docker ps | grep -q \"jimihub\"; then\n                echo -e \"${GREEN}✓ 容器启动成功${NC}\"\n            else\n                echo -e \"${RED}✗ 容器启动失败${NC}\"\n            fi\n        else\n            echo -e \"${RED}Docker Compose未找到${NC}\"\n        fi\n    else\n        echo -e \"${RED}请先安装JimiHub${NC}\"\n    fi\n}\n\n# 停止容器\nstop_container() {\n    if [ \"$INSTALLED\" = true ]; then\n        echo -e \"${YELLOW}正在停止容器...${NC}\"\n        cd \"$INSTALL_DIR\"\n        if check_docker_compose; then\n            $DOCKER_COMPOSE_CMD down\n            echo -e \"${GREEN}✓ 容器已停止${NC}\"\n        else\n            echo -e \"${RED}Docker Compose未找到${NC}\"\n        fi\n    else\n        echo -e \"${RED}请先安装JimiHub${NC}\"\n    fi\n}\n\n# 重启容器\nrestart_container() {\n    if [ \"$INSTALLED\" = true ]; then\n        echo -e \"${YELLOW}正在重启容器...${NC}\"\n        cd \"$INSTALL_DIR\"\n        if check_docker_compose; then\n            $DOCKER_COMPOSE_CMD restart\n            sleep 3\n            if docker ps | grep -q \"jimihub\"; then\n                echo -e \"${GREEN}✓ 容器重启成功${NC}\"\n            else\n                echo -e \"${RED}✗ 容器重启失败${NC}\"\n            fi\n        else\n            echo -e \"${RED}Docker Compose未找到${NC}\"\n        fi\n    else\n        echo -e \"${RED}请先安装JimiHub${NC}\"\n    fi\n}\n\n# 更新JimiHub\nupdate_jimihub() {\n    if [ \"$INSTALLED\" = false ]; then\n        echo -e \"${RED}JimiHub未安装，无法更新${NC}\"\n        return\n    fi\n\n    echo -e \"${YELLOW}开始更新JimiHub...${NC}\"\n    cd \"$INSTALL_DIR\"\n    if ! check_docker_compose; then\n        echo -e \"${RED}Docker Compose未找到${NC}\"\n        return\n    fi\n\n    echo \"正在拉取最新的Docker镜像...\"\n    if ! docker pull dreamhartley705/jimihub:latest; then\n        echo -e \"${RED}✗ 拉取最新镜像失败，请检查网络或镜像名称。${NC}\"\n        return\n    fi\n\n    echo \"正在停止并使用新镜像重新创建容器...\"\n    $DOCKER_COMPOSE_CMD up -d --force-recreate\n\n    sleep 5\n    if docker ps | grep -q \"jimihub\"; then\n        echo -e \"${GREEN}✓ 更新完成！${NC}\"\n    else\n        echo -e \"${RED}✗ 更新失败，请检查错误信息${NC}\"\n        echo -e \"${YELLOW}容器日志:${NC}\"\n        $DOCKER_COMPOSE_CMD logs\n    fi\n}\n\n# 卸载JimiHub\nuninstall_jimihub() {\n    if [ \"$INSTALLED\" = false ]; then\n        echo -e \"${RED}JimiHub未安装，无需卸载${NC}\"\n        return\n    fi\n\n    echo -e \"${YELLOW}警告：这将停止并删除JimiHub容器。${NC}\"\n    echo -n \"是否保留数据库文件? (y/n) [默认: y]: \"\n    read -r keep_data\n    keep_data=${keep_data:-y}\n\n    echo -e \"${YELLOW}开始卸载JimiHub...${NC}\"\n    \n    if [ -d \"$INSTALL_DIR\" ]; then\n        cd \"$INSTALL_DIR\"\n        if check_docker_compose; then\n            echo \"正在停止并删除JimiHub容器...\"\n            $DOCKER_COMPOSE_CMD down\n        fi\n        cd ..\n    fi\n\n    if [[ \"$keep_data\" == \"n\" || \"$keep_data\" == \"N\" ]]; then\n        echo \"正在删除安装目录（包括数据）...\"\n        rm -rf \"$INSTALL_DIR\"\n        echo -e \"${GREEN}✓ JimiHub及其数据已完全删除。${NC}\"\n    else\n        echo -e \"${GREEN}✓ JimiHub容器已停止。本地文件（包括数据）已保留在 $INSTALL_DIR ${NC}\"\n    fi\n\n    echo -n \"是否要卸载Docker? (y/n) [默认: n]: \"\n    read -r uninstall_docker\n    uninstall_docker=${uninstall_docker:-n}\n\n    if [[ \"$uninstall_docker\" == \"y\" || \"$uninstall_docker\" == \"Y\" ]]; then\n        # 检查是否还有其他Docker容器\n        if [ -n \"$(docker ps -aq)\" ]; then\n            echo -e \"${YELLOW}警告：检测到系统上存在其他Docker容器。为避免影响其他应用，Docker未被卸载。${NC}\"\n        else\n            echo \"正在卸载Docker...\"\n            if command -v apt-get &> /dev/null; then\n                apt-get purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras\n                apt-get autoremove -y --purge\n                rm -rf /var/lib/docker /etc/docker\n            elif command -v yum &> /dev/null; then\n                yum remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n                rm -rf /var/lib/docker /var/lib/containerd\n            elif command -v dnf &> /dev/null; then\n                dnf remove -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n                rm -rf /var/lib/docker /var/lib/containerd\n            fi\n            echo -e \"${GREEN}✓ Docker卸载完成。${NC}\"\n        fi\n    fi\n\n    unregister_command\n    echo -e \"${GREEN}✓ JimiHub卸载完成！感谢使用！${NC}\"\n    exit 0\n}\n\n# 注册系统命令\nregister_command() {\n    if [ ! -f \"$SCRIPT_PATH\" ]; then\n        cp \"$0\" \"$SCRIPT_PATH\"\n        chmod +x \"$SCRIPT_PATH\"\n        echo -e \"${GREEN}✓ 已注册系统命令 'jimihub'${NC}\"\n    fi\n}\n\n# 注销系统命令\nunregister_command() {\n    if [ -f \"$SCRIPT_PATH\" ]; then\n        rm -f \"$SCRIPT_PATH\"\n        echo -e \"${GREEN}✓ 已注销系统命令 'jimihub'${NC}\"\n    fi\n}\n\n\n# 显示菜单\nshow_menu() {\n    echo \"\"\n    echo -e \"${YELLOW}请选择操作:${NC}\"\n    echo \"1. 安装 JimiHub\"\n    echo \"2. 启动 JimiHub 容器\"\n    echo \"3. 停止 JimiHub 容器\"\n    echo \"4. 重启 JimiHub 容器\"\n    echo \"5. 更新 JimiHub\"\n    echo \"6. 卸载 JimiHub\"\n    echo \"0. 退出\"\n    echo \"\"\n    echo -n \"请输入选项 [0-6]: \"\n}\n\n# 主函数\nmain() {\n    check_root\n    \n    # 如果是卸载命令，直接执行\n    if [[ \"$1\" == \"uninstall\" ]]; then\n        check_install_status\n        uninstall_jimihub\n        exit 0\n    fi\n\n    register_command\n    \n    while true; do\n        show_welcome\n        check_install_status\n        check_container_status\n        show_menu\n        \n        read -r choice\n        case $choice in\n            1)\n                install_gemini_proxy_panel\n                echo \"\"\n                echo -n \"按回车键继续...\"\n                read -r\n                ;;\n            2)\n                start_container\n                echo \"\"\n                echo -n \"按回车键继续...\"\n                read -r\n                ;;\n            3)\n                stop_container\n                echo \"\"\n                echo -n \"按回车键继续...\"\n                read -r\n                ;;\n            4)\n                restart_container\n                echo \"\"\n                echo -n \"按回车键继续...\"\n                read -r\n                ;;\n            5)\n                update_jimihub\n                echo \"\"\n                echo -n \"按回车键继续...\"\n                read -r\n                ;;\n            6)\n                uninstall_jimihub\n                ;;\n            0)\n                echo -e \"${GREEN}感谢使用！${NC}\"\n                echo -e \"${BLUE}提示：下次可以直接使用 'jimihub' 命令进入管理脚本${NC}\"\n                exit 0\n                ;;\n            *)\n                echo -e \"${RED}无效选项，请重新选择${NC}\"\n                sleep 2\n                ;;\n        esac\n    done\n}\n\n# 运行主函数\nmain \"$@\""
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"jimihub\",\n  \"version\": \"1.0.0\",\n  \"main\": \"src/index.js\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\",\n    \"start\": \"node src/index.js\"\n  },\n  \"keywords\": [],\n  \"author\": \"dream_hartley\",\n  \"license\": \"Apache-2.0\",\n  \"description\": \"A Gemini to OpenAI format proxy supporting multi-apikey rotation. Supports multiple deployment methods.\",\n  \"dependencies\": {\n    \"@google-cloud/vertexai\": \"^0.5.0\",\n    \"@google/genai\": \"^0.12.0\",\n    \"@octokit/rest\": \"^20.0.0\",\n    \"cookie-parser\": \"^1.4.7\",\n    \"cors\": \"^2.8.5\",\n    \"dotenv\": \"^16.4.7\",\n    \"express\": \"^4.18.2\",\n    \"mime-types\": \"^2.1.35\",\n    \"node-cron\": \"^4.2.1\",\n    \"node-fetch\": \"^2.7.0\",\n    \"socks-proxy-agent\": \"^8.0.3\",\n    \"sqlite3\": \"^5.1.7\",\n    \"uuid\": \"^9.0.1\"\n  }\n}\n"
  },
  {
    "path": "public/admin/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>JimiHub</title>\n    <script src=\"https://cdn.tailwindcss.com?plugins=forms\"></script>\n    <link rel=\"icon\" href=\"/favicon.ico\" type=\"image/x-icon\">\n    <link rel=\"stylesheet\" href=\"style.css\">\n    <script src=\"../i18n.js\"></script>\n    <style>\n        /* Hide dropdown arrow for datalist inputs */\n        input::-webkit-calendar-picker-indicator {\n            display: none !important;\n        }\n        \n        /* Simple loading spinner */\n        .loader {\n            border: 4px solid #f3f3f3; /* Light grey */\n            border-top: 4px solid #3498db; /* Blue */\n            border-radius: 50%;\n            width: 24px;\n            height: 24px;\n            animation: spin 1s linear infinite;\n            display: inline-block; /* Initially hidden */\n            margin-left: 10px;\n            vertical-align: middle;\n        }\n        @keyframes spin {\n            0% { transform: rotate(0deg); }\n            100% { transform: rotate(360deg); }\n        }\n\n        /* Custom scrollbar styles for keys grid */\n        .keys-grid::-webkit-scrollbar {\n            width: 8px;\n        }\n\n        .keys-grid::-webkit-scrollbar-track {\n            background: #f1f5f9;\n            border-radius: 4px;\n        }\n\n        .keys-grid::-webkit-scrollbar-thumb {\n            background: #cbd5e1;\n            border-radius: 4px;\n            transition: background-color 0.2s;\n        }\n\n        .keys-grid::-webkit-scrollbar-thumb:hover {\n            background: #94a3b8;\n        }\n\n        /* For Firefox */\n        .keys-grid {\n            scrollbar-width: thin;\n            scrollbar-color: #cbd5e1 #f1f5f9;\n        }\n\n        /* Light mode scrollbar adjustments for warm theme */\n        body[data-theme=\"light\"] .keys-grid::-webkit-scrollbar-track {\n            background: #ede8e0; /* Warmer scrollbar track */\n        }\n\n        body[data-theme=\"light\"] .keys-grid {\n            scrollbar-color: #cbd5e1 #ede8e0; /* Warmer scrollbar for Firefox */\n        }\n        /* Progress ring styles */\n        .progress-ring__circle {\n            transition: stroke-dashoffset 0.35s;\n            transform: rotate(-90deg);\n            transform-origin: 50% 50%;\n        }\n        \n\n    </style>\n    <script>\n        (function() {\n            try {\n                const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';\n\n                if (!isLoggedIn) {\n                    window.location.href = '/login';\n                }\n            } catch (e) {\n                console.error('Auth check error:', e);\n                window.location.href = '/login';\n            }\n        })();\n    </script>\n</head>\n<body class=\"bg-gray-100 font-sans leading-normal tracking-normal\" data-theme=\"light\">\n    <!-- Authentication Checking UI -->\n    <div id=\"auth-checking\" class=\"fixed inset-0 bg-gray-700 bg-opacity-75 flex items-center justify-center z-50\">\n        <div class=\"bg-white p-8 rounded-lg shadow-xl text-center\">\n            <div class=\"loader mx-auto mb-4\"></div>\n            <p class=\"text-lg text-gray-700\" data-i18n=\"verifying_identity\">正在验证身份...</p>\n            <p class=\"text-sm text-gray-500 mt-2\"><span data-i18n=\"auth_check_timeout\">如果页面长时间无响应，请</span> <a href=\"/login\" class=\"text-blue-600 hover:underline\" data-i18n=\"return_to_login\">返回登录页面</a></p>\n        </div>\n    </div>\n    <!-- Unauthorized UI -->\n    <div id=\"unauthorized\" class=\"fixed inset-0 bg-gray-700 bg-opacity-75 flex items-center justify-center z-50 hidden\">\n        <div class=\"bg-white p-8 rounded-lg shadow-xl text-center max-w-md\">\n            <div class=\"text-red-500 mb-4\">\n                <svg class=\"w-16 h-16 mx-auto\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\"></path>\n                </svg>\n            </div>\n            <h2 class=\"text-xl font-bold text-gray-800 mb-4\">Unauthorized Access</h2>\n            <p class=\"text-gray-600 mb-6\" data-i18n=\"need_login_message\">您需要登录才能访问管理页面。</p>\n            <a href=\"/login\" class=\"bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline inline-block\" data-i18n=\"go_to_login\">\n                前往登录\n            </a>\n        </div>\n    </div>\n    <div id=\"main-content\" class=\"hidden\">\n        <div class=\"container mx-auto p-4 lg:p-8\">\n            <div class=\"flex justify-between items-center mb-6\">\n                <h1 class=\"text-2xl lg:text-3xl font-bold text-gray-800\">JimiHub<span id=\"update-notifier\" class=\"hidden\"></span></h1>\n                <div class=\"flex items-center space-x-2\">\n                    <button id=\"settings-button\" class=\"bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium p-2 rounded flex items-center justify-center h-[38px] w-[38px]\" title=\"系统设置\" data-i18n-title=\"system_settings\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n                        </svg>\n                    </button>\n                    <button id=\"dark-mode-toggle\" class=\"bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium p-2 rounded flex items-center justify-center h-[38px] w-[38px]\">\n                        <svg id=\"sun-icon\" class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z\"></path>\n                        </svg>\n                        <svg id=\"moon-icon\" class=\"w-5 h-5 hidden\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z\"></path>\n                        </svg>\n                    </button>\n                    <button id=\"logout-button\" class=\"bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium p-2 rounded flex items-center justify-center h-[38px] w-[38px]\" title=\"退出登录\" data-i18n-title=\"logout\">\n                        <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1\"></path>\n                        </svg>\n                    </button>\n                </div>\n            </div>\n            <!-- Loading Indicator -->\n            <div id=\"loading-indicator\" class=\"fixed top-4 right-4 bg-blue-500 text-white p-2 rounded shadow hidden\">\n                <div class=\"loader\"></div> <span data-i18n=\"loading\">加载中...</span>\n            </div>\n            <!-- Error Message Area -->\n            <div id=\"error-message\" class=\"bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4 hidden\" role=\"alert\">\n                <strong class=\"font-bold\" data-i18n=\"error\">错误:</strong>\n                <span class=\"block sm:inline\" id=\"error-text\"></span>\n            </div>\n            <!-- Success Message Area -->\n            <div id=\"success-message\" class=\"bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4 hidden\" role=\"alert\">\n                <strong class=\"font-bold\" data-i18n=\"success\">成功:</strong>\n                <span class=\"block sm:inline\" id=\"success-text\"></span>\n            </div>\n        <!-- API Configuration Section -->\n        <section class=\"mb-8 bg-white p-6 rounded-lg shadow\">\n            <!-- Tab Navigation -->\n            <div class=\"border-b border-gray-200 mb-6\">\n                <nav class=\"-mb-px flex space-x-8\" aria-label=\"Tabs\">\n                    <button id=\"gemini-tab\" class=\"api-tab active whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm\" data-tab=\"gemini\">\n                        AI Studio\n                    </button>\n                    <button id=\"vertex-tab\" class=\"api-tab whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm\" data-tab=\"vertex\">\n                        Vertex\n                    </button>\n                </nav>\n            </div>\n\n            <!-- Gemini API Keys Tab Content -->\n            <div id=\"gemini-content\" class=\"tab-content\">\n                <h2 class=\"text-xl font-semibold mb-4 text-gray-700\">Gemini API Keys</h2>\n            <!-- Test Progress Area -->\n            <div id=\"test-progress-area\" class=\"hidden mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg\">\n                <div class=\"flex items-center justify-between mb-2\">\n                    <h3 class=\"text-lg font-medium text-blue-800\" data-i18n=\"running_all_tests\">正在运行所有测试</h3>\n                    <button id=\"cancel-all-test-btn\" class=\"text-red-600 hover:text-red-800 font-medium px-3 py-1 border border-red-600 rounded\" data-i18n=\"cancel\">\n                        取消\n                    </button>\n                </div>\n                <div class=\"mb-2\">\n                    <div class=\"flex justify-between text-sm text-blue-700 mb-1\">\n                        <span data-i18n=\"progress\">进度</span>\n                        <span id=\"test-progress-text\">0 / 0</span>\n                    </div>\n                    <div class=\"w-full bg-blue-200 rounded-full h-2\">\n                        <div id=\"test-progress-bar\" class=\"bg-blue-600 h-2 rounded-full transition-all duration-300\" style=\"width: 0%\"></div>\n                    </div>\n                </div>\n                <div id=\"test-status-text\" class=\"text-sm text-blue-700\" data-i18n=\"preparing_tests\">\n                    准备测试中...\n                </div>\n            </div>\n            <div id=\"gemini-keys-list\" class=\"mb-4 space-y-4\">\n                <!-- Key items will be loaded here -->\n                <p class=\"text-gray-500\" data-i18n=\"loading_keys\">加载密钥中...</p>\n            </div>\n            <!-- Test and Clean Buttons Area -->\n            <div id=\"gemini-keys-actions\" class=\"hidden mb-6 flex space-x-3\">\n                <button type=\"button\" id=\"run-all-test-btn\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 run-test-btn\" data-i18n=\"run_all_test\">\n                    运行所有测试\n                </button>\n                <button type=\"button\" id=\"ignore-all-errors-btn\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500\" data-i18n=\"ignore_all_errors\">\n                    忽略所有报错\n                </button>\n                <button type=\"button\" id=\"clean-error-keys-btn\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500\" data-i18n=\"clean_error_keys\">\n                    清理报错密钥\n                </button>\n            </div>\n            <form id=\"add-gemini-key-form\" class=\"space-y-3\">\n                <h3 class=\"text-lg font-medium\" data-i18n=\"add_new_gemini_key\">添加新的 Gemini 密钥</h3>\n                <div>\n                    <label for=\"gemini-key-name\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"name_optional\">名称（可选）</label>\n                    <input type=\"text\" id=\"gemini-key-name\" name=\"name\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"例如：个人密钥\" data-i18n-placeholder=\"name_placeholder\">\n                    <p class=\"text-xs text-gray-500 mt-1\" data-i18n=\"name_help\">用于识别的友好名称。如果未提供，将使用自动生成的ID。</p>\n                </div>\n                <div>\n                    <label for=\"gemini-key-value\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"api_key_value\">API 密钥值</label>\n                    <textarea id=\"gemini-key-value\" name=\"key\" required rows=\"3\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"请输入 Gemini API 密钥\" data-i18n-placeholder=\"enter_gemini_api_key\"></textarea>\n                    <p class=\"text-xs text-gray-500 mt-1\" data-i18n=\"gemini_key_batch_help\">支持批量添加：使用逗号分隔多个密钥，或每行一个密钥。</p>\n                </div>\n                <!-- Removed Daily Quota input for Gemini Key -->\n                <div class=\"flex space-x-3\">\n                    <button type=\"submit\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"add_gemini_key\">\n                        添加 Gemini 密钥\n                    </button>\n                </div>\n            </form>\n            </div>\n\n            <!-- Vertex Configuration Tab Content -->\n            <div id=\"vertex-content\" class=\"tab-content hidden\">\n                <h2 class=\"text-xl font-semibold mb-4 text-gray-700\">Vertex AI Configuration</h2>\n\n                <!-- Current Vertex Configuration Display -->\n                <div id=\"vertex-config-display\" class=\"mb-6 p-4 bg-gray-50 border border-gray-200 rounded-lg\">\n                    <div class=\"flex items-center justify-between mb-2\">\n                        <h3 class=\"text-lg font-medium text-gray-700\" data-i18n=\"current_vertex_config\">当前 Vertex 配置</h3>\n                        <div class=\"flex items-center space-x-2\">\n                            <span id=\"vertex-status\" class=\"text-sm font-medium\"></span>\n                        </div>\n                    </div>\n                    <div id=\"vertex-config-info\" class=\"text-sm text-gray-600\">\n                        <p data-i18n=\"loading_vertex_config\">加载配置中...</p>\n                    </div>\n                </div>\n\n                <!-- Vertex Configuration Form -->\n                <form id=\"vertex-config-form\" class=\"space-y-4\">\n                    <h3 class=\"text-lg font-medium\" data-i18n=\"vertex_config_title\">Vertex AI 配置</h3>\n\n                    <!-- Authentication Mode Selection -->\n                    <div>\n                        <label class=\"block text-sm font-medium text-gray-700 mb-2\" data-i18n=\"auth_mode\">认证模式</label>\n                        <div class=\"space-y-2\">\n                            <label class=\"inline-flex items-center\">\n                                <input type=\"radio\" name=\"auth_mode\" value=\"service_account\" class=\"form-radio\" checked>\n                                <span class=\"ml-2 text-sm text-gray-700\" data-i18n=\"service_account_mode\">服务账号 (JSON)</span>\n                            </label>\n                            <label class=\"inline-flex items-center\">\n                                <input type=\"radio\" name=\"auth_mode\" value=\"express\" class=\"form-radio\">\n                                <span class=\"ml-2 text-sm text-gray-700\" data-i18n=\"express_mode\">快捷模式 (API Key)</span>\n                            </label>\n                        </div>\n                    </div>\n\n                    <!-- Service Account JSON Input -->\n                    <div id=\"service-account-section\">\n                        <label for=\"vertex-json\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"service_account_json\">Service Account JSON</label>\n                        <textarea id=\"vertex-json\" name=\"vertex_json\" rows=\"8\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"请输入 Vertex AI Service Account JSON\" data-i18n-placeholder=\"enter_vertex_json\"></textarea>\n                        <p class=\"text-xs text-gray-500 mt-1\" data-i18n=\"vertex_json_help\">完整的 Google Cloud Service Account JSON 配置</p>\n                    </div>\n\n                    <!-- Express API Key Input -->\n                    <div id=\"express-api-key-section\" class=\"hidden\">\n                        <label for=\"express-api-key\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"express_api_key\">Express API Key</label>\n                        <input type=\"text\" id=\"express-api-key\" name=\"express_api_key\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"请输入 Vertex AI Express API Key\" data-i18n-placeholder=\"enter_express_api_key\">\n                        <p class=\"text-xs text-gray-500 mt-1\" data-i18n=\"express_api_key_help\">用于 Vertex AI Express Mode 的 API Key</p>\n                    </div>\n\n                    <div class=\"flex space-x-3\">\n                        <button type=\"submit\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"save_vertex_config\">\n                            保存 Vertex 配置\n                        </button>\n                        <button type=\"button\" id=\"test-vertex-config\" class=\"inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"test_vertex_config\">\n                            测试配置\n                        </button>\n                        <button type=\"button\" id=\"clear-vertex-config\" class=\"inline-flex justify-center py-2 px-4 border border-red-300 shadow-sm text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500\" data-i18n=\"clear_vertex_config\">\n                            清除配置\n                        </button>\n                    </div>\n                </form>\n            </div>\n        </section>\n        <!-- Worker API Keys Section -->\n        <section class=\"mb-8 bg-white p-6 rounded-lg shadow\">\n            <h2 class=\"text-xl font-semibold mb-4 text-gray-700\">Worker API Keys</h2>\n             <div id=\"worker-keys-list\" class=\"mb-4 space-y-2\">\n                <!-- Key items will be loaded here -->\n                 <p class=\"text-gray-500\" data-i18n=\"loading_keys\">加载密钥中...</p>\n            </div>\n            <!-- Worker Keys Legend/Help -->\n            <div class=\"bg-blue-50 p-3 rounded mb-4 text-sm text-blue-800\">\n                <p data-i18n=\"safety_settings_help\"><strong>安全设置：</strong> 默认启用。禁用时，模型允许生成 NSFW 内容。</p>\n            </div>\n            <form id=\"add-worker-key-form\" class=\"space-y-3\">\n                 <h3 class=\"text-lg font-medium\" data-i18n=\"add_new_worker_key\">添加新的 Worker 密钥</h3>\n                <div>\n                    <label for=\"worker-key-value\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"api_key_value\">API 密钥值</label>\n                    <input type=\"text\" id=\"worker-key-value\" name=\"key\" required class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"生成或输入强密钥\" data-i18n-placeholder=\"generate_or_enter_key\">\n                     <button type=\"button\" id=\"generate-worker-key\" class=\"mt-1 text-sm text-indigo-600 hover:text-indigo-800\" data-i18n=\"generate_random_key\">生成随机密钥</button>\n                </div>\n                 <div>\n                    <label for=\"worker-key-desc\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"description_optional\">描述（可选）</label>\n                    <input type=\"text\" id=\"worker-key-desc\" name=\"description\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"例如：客户端应用 A\" data-i18n-placeholder=\"description_placeholder\">\n                </div>\n                <button type=\"submit\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"add_worker_key\">\n                    添加 Worker 密钥\n                </button>\n            </form>\n        </section>\n        <!-- Models Section -->\n        <section class=\"bg-white p-6 rounded-lg shadow\">\n            <div class=\"flex justify-between items-center mb-4\">\n                <h2 class=\"text-xl font-semibold text-gray-700\">Managed Models</h2>\n                <button id=\"set-category-quotas-btn\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500\" data-i18n=\"set_category_quotas\">\n                    设置类别配额\n                </button>\n            </div>\n             <div id=\"models-list\" class=\"mb-4 space-y-2\">\n                <!-- Model items will be loaded here -->\n                 <p class=\"text-gray-500\" data-i18n=\"loading_models\">加载模型中...</p>\n            </div>\n            <form id=\"add-model-form\" class=\"space-y-3\">\n                 <h3 class=\"text-lg font-medium\" data-i18n=\"add_model\">添加模型</h3>\n                <div>\n                    <label for=\"model-id\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"model_id\">模型 ID</label>\n                    <div class=\"relative\">\n                        <input type=\"text\" id=\"model-id\" name=\"id\" required list=\"model-suggestions\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"例如：gemini-1.5-flash-latest\" data-i18n-placeholder=\"model_id_placeholder\" autocomplete=\"off\">\n                        <datalist id=\"model-suggestions\">\n                        </datalist>\n                    </div>\n                    <p class=\"text-xs text-gray-500 mt-1\" data-i18n=\"model_id_help\">选择或输入模型 ID</p>\n                </div>\n                 <div>\n                    <label for=\"model-category\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"category\">类别</label>\n                    <select id=\"model-category\" name=\"category\" required class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\">\n                        <option value=\"Pro\">Pro</option>\n                        <option value=\"Flash\">Flash</option>\n                        <option value=\"Custom\">Custom</option>\n                    </select>\n                </div>\n                <div id=\"custom-quota-div\" class=\"hidden\"> <!-- Hidden by default -->\n                    <label for=\"model-quota\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"daily_quota_custom\">每日配额（自定义）</label>\n                    <input type=\"text\" id=\"model-quota\" name=\"dailyQuota\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"例如：1500，或 'none'/'0' 表示无限制\" data-i18n-placeholder=\"quota_placeholder\">\n                    <p class=\"text-xs text-gray-500 mt-1\" data-i18n=\"quota_help\">仅适用于\"自定义\"类别。设置此特定模型的每日最大请求数。输入 'none' 或 '0' 表示无限制。</p>\n                </div>\n                <button type=\"submit\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"add_model\">\n                    添加模型\n                </button>\n            </form>\n        </section>\n    </div>\n\n    <!-- Set Category Quotas Modal -->\n    <div id=\"category-quotas-modal\" class=\"fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 hidden\">\n        <div class=\"bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 modal-content\">\n            <div class=\"flex justify-between items-center mb-4\">\n                <h2 class=\"text-xl font-bold text-gray-800\" data-i18n=\"set_category_quotas_title\">设置类别配额</h2>\n                <button id=\"close-category-quotas-modal\" class=\"text-gray-500 hover:text-gray-800\">\n                    <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                    </svg>\n                </button>\n            </div>\n            <form id=\"category-quotas-form\" class=\"space-y-4\">\n                <div>\n                    <label for=\"pro-quota\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"pro_models_daily_quota\">Pro 模型每日配额</label>\n                    <input type=\"number\" id=\"pro-quota\" name=\"proQuota\" required min=\"0\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"默认：50\">\n                </div>\n                <div>\n                    <label for=\"flash-quota\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"flash_models_daily_quota\">Flash 模型每日配额</label>\n                    <input type=\"number\" id=\"flash-quota\" name=\"flashQuota\" required min=\"0\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"默认：1500\">\n                </div>\n                <div class=\"flex justify-end space-x-2\">\n                    <button type=\"button\" id=\"cancel-category-quotas\" class=\"py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"cancel\">\n                        取消\n                    </button>\n                    <button type=\"submit\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"save_quotas\">\n                        保存配额\n                    </button>\n                </div>\n            </form>\n            <div id=\"category-quotas-error\" class=\"text-red-500 text-sm mt-2 hidden\"></div>\n        </div>\n    </div>\n\n    <!-- Individual Quota Modal -->\n    <div id=\"individual-quota-modal\" class=\"fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 hidden\">\n        <div class=\"bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4 modal-content\">\n            <div class=\"flex justify-between items-center mb-4\">\n                <h2 class=\"text-xl font-bold text-gray-800\" data-i18n=\"set_quota\">设置配额</h2>\n                <button id=\"close-individual-quota-modal\" class=\"text-gray-500 hover:text-gray-800\">\n                    <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                    </svg>\n                </button>\n            </div>\n            <form id=\"individual-quota-form\" class=\"space-y-4\">\n                <input type=\"hidden\" id=\"individual-quota-model-id\" name=\"modelId\" value=\"\">\n                <div>\n                    <label for=\"individual-quota-value\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"individual_daily_quota\">个人每日配额</label>\n                    <input type=\"number\" id=\"individual-quota-value\" name=\"individualQuota\" min=\"0\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"默认：0（无个人配额）\" data-i18n-placeholder=\"individual_quota_placeholder\">\n                    <p class=\"text-xs text-gray-500 mt-1\" data-i18n=\"individual_quota_help\">输入 0 表示无个人配额。个人配额是在类别配额基础上额外应用的。</p>\n                </div>\n                <!-- Removed the note/priority/applicable section -->\n                <div class=\"flex justify-end space-x-2\">\n                    <button type=\"button\" id=\"cancel-individual-quota\" class=\"py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"cancel\">\n                        取消\n                    </button>\n                    <button type=\"submit\" class=\"inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500\" data-i18n=\"save_quota\">\n                        保存配额\n                    </button>\n                </div>\n            </form>\n            <div id=\"individual-quota-error\" class=\"text-red-500 text-sm mt-2 hidden\"></div>\n        </div>\n    </div>\n\n    <!-- System Settings Modal -->\n    <div id=\"settings-modal\" class=\"fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full hidden z-50\">\n        <div class=\"relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white\">\n            <div class=\"mt-3\">\n                <div class=\"flex items-center justify-between mb-4\">\n                    <h3 class=\"text-lg font-medium text-gray-900\" data-i18n=\"system_settings\">系统设置</h3>\n                    <button id=\"close-settings-modal\" class=\"text-gray-400 hover:text-gray-600\">\n                        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                        </svg>\n                    </button>\n                </div>\n\n                <form id=\"settings-form\" class=\"space-y-4\">\n                    <!-- KEEPALIVE Setting -->\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <label class=\"text-sm font-medium text-gray-700\" data-i18n=\"keepalive_setting\">KEEPALIVE 模式</label>\n                            <p class=\"text-xs text-gray-500\" data-i18n=\"keepalive_description\">启用后将使用保持连接模式处理请求</p>\n                        </div>\n                        <label class=\"toggle-switch\">\n                            <input type=\"checkbox\" id=\"keepalive-toggle\">\n                            <span class=\"toggle-slider\"></span>\n                        </label>\n                    </div>\n\n                    <!-- Web Search Setting -->\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <label class=\"text-sm font-medium text-gray-700\" data-i18n=\"web_search_setting\">联网搜索</label>\n                            <p class=\"text-xs text-gray-500\" data-i18n=\"web_search_description\">启用后将在模型列表中显示带-search后缀的联网搜索模型</p>\n                        </div>\n                        <label class=\"toggle-switch\">\n                            <input type=\"checkbox\" id=\"web-search-toggle\">\n                            <span class=\"toggle-slider\"></span>\n                        </label>\n                    </div>\n\n                    <!-- Auto Test Setting -->\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <label class=\"text-sm font-medium text-gray-700\" data-i18n=\"auto_test_setting\">自动批量测试</label>\n                            <p class=\"text-xs text-gray-500\" data-i18n=\"auto_test_description\">启用后将在每天北京时间4点自动进行批量测试</p>\n                        </div>\n                        <label class=\"toggle-switch\">\n                            <input type=\"checkbox\" id=\"auto-test-toggle\">\n                            <span class=\"toggle-slider\"></span>\n                        </label>\n                    </div>\n\n                    <!-- MAX_RETRY Setting -->\n                    <div>\n                        <label for=\"max-retry-input\" class=\"block text-sm font-medium text-gray-700\" data-i18n=\"max_retry_setting\">最大重试次数</label>\n                        <p class=\"text-xs text-gray-500 mb-2\" data-i18n=\"max_retry_description\">API请求失败时的最大重试次数（默认：3）</p>\n                        <input type=\"number\" id=\"max-retry-input\" min=\"0\" max=\"10\" class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\" placeholder=\"3\">\n                    </div>\n\n                    <div class=\"flex justify-end space-x-3 pt-4\">\n                        <button type=\"button\" id=\"cancel-settings\" class=\"px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md\" data-i18n=\"cancel\">取消</button>\n                        <button type=\"submit\" class=\"px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md\" data-i18n=\"save\">保存</button>\n                    </div>\n                </form>\n            </div>\n        </div>\n    </div>\n\n    <script src=\"script.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "public/admin/script.js",
    "content": "document.addEventListener('DOMContentLoaded', () => {\n    // --- UI Elements ---\n    const authCheckingUI = document.getElementById('auth-checking');\n    const unauthorizedUI = document.getElementById('unauthorized');\n    const mainContentUI = document.getElementById('main-content');\n\n    // --- Global State & Elements ---\n    const loadingIndicator = document.getElementById('loading-indicator');\n    const errorMessageDiv = document.getElementById('error-message');\n    const errorTextSpan = document.getElementById('error-text');\n    const successMessageDiv = document.getElementById('success-message');\n    const successTextSpan = document.getElementById('success-text');\n    const geminiKeysListDiv = document.getElementById('gemini-keys-list');\n    const addGeminiKeyForm = document.getElementById('add-gemini-key-form');\n    const workerKeysListDiv = document.getElementById('worker-keys-list');\n    const addWorkerKeyForm = document.getElementById('add-worker-key-form');\n    const generateWorkerKeyBtn = document.getElementById('generate-worker-key');\n\n    // Tab elements\n    const geminiTab = document.getElementById('gemini-tab');\n    const vertexTab = document.getElementById('vertex-tab');\n    const geminiContent = document.getElementById('gemini-content');\n    const vertexContent = document.getElementById('vertex-content');\n\n    // Vertex configuration elements\n    const vertexConfigForm = document.getElementById('vertex-config-form');\n    const vertexConfigDisplay = document.getElementById('vertex-config-display');\n    const vertexConfigInfo = document.getElementById('vertex-config-info');\n    const vertexStatus = document.getElementById('vertex-status');\n    const testVertexConfigBtn = document.getElementById('test-vertex-config');\n    const clearVertexConfigBtn = document.getElementById('clear-vertex-config');\n    const workerKeyValueInput = document.getElementById('worker-key-value');\n    const modelsListDiv = document.getElementById('models-list');\n    const addModelForm = document.getElementById('add-model-form');\n    const modelCategorySelect = document.getElementById('model-category');\n    const customQuotaDiv = document.getElementById('custom-quota-div');\n    const modelQuotaInput = document.getElementById('model-quota');\n    const modelIdInput = document.getElementById('model-id');\n    const setCategoryQuotasBtn = document.getElementById('set-category-quotas-btn');\n    const categoryQuotasModal = document.getElementById('category-quotas-modal');\n    const closeCategoryQuotasModalBtn = document.getElementById('close-category-quotas-modal');\n    const cancelCategoryQuotasBtn = document.getElementById('cancel-category-quotas');\n    const categoryQuotasForm = document.getElementById('category-quotas-form');\n    const proQuotaInput = document.getElementById('pro-quota');\n    const flashQuotaInput = document.getElementById('flash-quota');\n    const categoryQuotasErrorDiv = document.getElementById('category-quotas-error');\n    const geminiKeyErrorContainer = document.getElementById('gemini-key-error-container'); // Container for error messages in modal\n    // Individual Quota Elements\n    const individualQuotaModal = document.getElementById('individual-quota-modal');\n    const closeIndividualQuotaModalBtn = document.getElementById('close-individual-quota-modal');\n    const cancelIndividualQuotaBtn = document.getElementById('cancel-individual-quota');\n    const individualQuotaForm = document.getElementById('individual-quota-form');\n    const individualQuotaModelIdInput = document.getElementById('individual-quota-model-id');\n    const individualQuotaValueInput = document.getElementById('individual-quota-value');\n    const individualQuotaErrorDiv = document.getElementById('individual-quota-error');\n    const logoutButton = document.getElementById('logout-button');\n    const darkModeToggle = document.getElementById('dark-mode-toggle');\n    const sunIcon = document.getElementById('sun-icon');\n    const moonIcon = document.getElementById('moon-icon');\n    // Run All Test Elements\n    const runAllTestBtn = document.getElementById('run-all-test-btn');\n    const ignoreAllErrorsBtn = document.getElementById('ignore-all-errors-btn');\n    const cleanErrorKeysBtn = document.getElementById('clean-error-keys-btn');\n    const geminiKeysActionsDiv = document.getElementById('gemini-keys-actions');\n    const testProgressArea = document.getElementById('test-progress-area');\n    const cancelAllTestBtn = document.getElementById('cancel-all-test-btn');\n    const testProgressBar = document.getElementById('test-progress-bar');\n    const testProgressText = document.getElementById('test-progress-text');\n    const testStatusText = document.getElementById('test-status-text');\n\n    // --- Global Cache ---\n    let cachedModels = [];\n    let cachedGeminiModels = []; // Add cache for available Gemini models\n    let cachedCategoryQuotas = { proQuota: 0, flashQuota: 0 };\n\n    // --- Global Test State ---\n    let isRunningAllTests = false;\n    let testCancelRequested = false;\n    let currentTestBatch = [];\n    let operationInProgress = false; // Prevent concurrent database operations\n    // No need for a separate errorKeyIds cache, as errorStatus is now part of the key data\n\n    // --- Run All Test Functions ---\n    async function runAllGeminiKeysTest() {\n        // Prevent concurrent operations\n        if (operationInProgress) {\n            showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');\n            return;\n        }\n\n        try {\n            operationInProgress = true; // Lock operations\n            isRunningAllTests = true;\n            testCancelRequested = false;\n\n            // Show progress area\n            testProgressArea.classList.remove('hidden');\n            runAllTestBtn.disabled = true;\n            cancelAllTestBtn.disabled = false;\n\n            // Get all Gemini keys\n            const keys = await apiFetch('/gemini-keys');\n            if (!keys || keys.length === 0) {\n                showError(t('no_gemini_keys_found'));\n                return;\n            }\n\n            const totalKeys = keys.length;\n            let completedTests = 0;\n            const testModel = 'gemini-2.0-flash'; // Fixed model for testing\n\n            // Update initial progress\n            updateTestProgress(completedTests, totalKeys, t('preparing_tests'));\n\n            // Process keys in batches to balance performance and server load\n            const batchSize = 5; // Optimal batch size for testing\n            for (let i = 0; i < keys.length; i += batchSize) {\n                if (testCancelRequested) {\n                    break;\n                }\n\n                const batch = keys.slice(i, i + batchSize);\n                currentTestBatch = batch;\n\n                updateTestProgress(completedTests, totalKeys, t('testing_batch', Math.floor(i / batchSize) + 1));\n\n                // Run tests for current batch concurrently\n                const batchPromises = batch.map(key => testSingleKey(key.id, testModel));\n                const batchResults = await Promise.allSettled(batchPromises);\n\n                // Update progress\n                completedTests += batch.length;\n                updateTestProgress(completedTests, totalKeys, t('completed_tests', completedTests, totalKeys));\n\n                // Increased delay between batches to reduce server load\n                if (i + batchSize < keys.length && !testCancelRequested) {\n                    await new Promise(resolve => setTimeout(resolve, 1000)); // Increased from 500ms to 1000ms\n                }\n            }\n\n            // Final status\n            if (testCancelRequested) {\n                updateTestProgress(completedTests, totalKeys, t('tests_cancelled'));\n                showError(t('test_run_cancelled'));\n            } else {\n                updateTestProgress(completedTests, totalKeys, t('all_tests_completed'));\n                showSuccess(t('completed_testing', totalKeys, testModel));\n\n                // Auto-hide progress area after 3 seconds\n                setTimeout(() => {\n                    testProgressArea.classList.add('hidden');\n                }, 3000);\n            }\n\n            // Reload keys to show updated status\n            await loadGeminiKeys();\n\n        } catch (error) {\n            console.error('Error running all tests:', error);\n            showError(t('failed_to_run_tests', error.message));\n            updateTestProgress(0, 0, t('test_run_failed'));\n        } finally {\n            operationInProgress = false; // Release lock\n            isRunningAllTests = false;\n            runAllTestBtn.disabled = false;\n            cancelAllTestBtn.disabled = true;\n            currentTestBatch = [];\n        }\n    }\n\n    async function testSingleKey(keyId, modelId) {\n        try {\n            // Use direct fetch to get detailed error information\n            const response = await fetch('/api/admin/test-gemini-key', {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n                credentials: 'include',\n                body: JSON.stringify({ keyId, modelId })\n            });\n\n            // Parse response regardless of status code\n            let result = null;\n            const contentType = response.headers.get(\"content-type\");\n            if (contentType && contentType.indexOf(\"application/json\") !== -1) {\n                result = await response.json();\n            } else {\n                const textContent = await response.text();\n                result = {\n                    success: false,\n                    status: response.status,\n                    content: textContent || 'No response content'\n                };\n            }\n\n            return {\n                keyId,\n                success: result?.success || false,\n                status: result?.status || response.status,\n                error: result?.success ? null : (result?.content || 'Test failed')\n            };\n        } catch (error) {\n            return {\n                keyId,\n                success: false,\n                status: 'error',\n                error: error.message || 'Network error'\n            };\n        }\n    }\n\n    function updateTestProgress(completed, total, statusMessage) {\n        const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;\n\n        testProgressBar.style.width = `${percentage}%`;\n        testProgressText.textContent = `${completed} / ${total}`;\n        testStatusText.textContent = statusMessage;\n    }\n\n    // --- Utility Functions ---\n    function showLoading() {\n        loadingIndicator.classList.remove('hidden');\n    }\n\n    function hideLoading() {\n        loadingIndicator.classList.add('hidden');\n    }\n\n    // Function to enable/disable add model form based on Gemini keys availability\n    function updateAddModelFormState(hasGeminiKeys) {\n        const addModelFormElements = addModelForm.querySelectorAll('input, select, button');\n        addModelFormElements.forEach(element => {\n            element.disabled = !hasGeminiKeys;\n        });\n\n        // Add visual indication when disabled\n        if (hasGeminiKeys) {\n            addModelForm.classList.remove('opacity-50', 'pointer-events-none');\n        } else {\n            addModelForm.classList.add('opacity-50', 'pointer-events-none');\n        }\n    }\n\n    function showError(message, element = errorTextSpan, container = errorMessageDiv) {\n        element.textContent = message;\n        container.classList.remove('hidden');\n        // Auto-hide after 5 seconds\n        setTimeout(() => {\n            hideError(container);\n        }, 5000);\n    }\n\nfunction hideError(container = errorMessageDiv) {\n    container.classList.add('hidden');\n    const textSpan = container.querySelector('span#error-text'); \n    if (textSpan) textSpan.textContent = ''; // Only clear the message span\n}\n\n    // Function to show success message and auto-hide\n    function showSuccess(message, element = successTextSpan, container = successMessageDiv) {\n        element.textContent = message;\n        container.classList.remove('hidden');\n        // Auto-hide after 3 seconds\n        setTimeout(() => {\n            hideSuccess(container);\n        }, 3000);\n    }\n\n    // Function to hide success message\n    function hideSuccess(container = successMessageDiv) {\n        container.classList.add('hidden');\n        const textSpan = container.querySelector('span');\n        if (textSpan) textSpan.textContent = '';\n    }\n\n    // Generic API fetch function (using cookie auth now)\n    // 新增 suppressGlobalError 参数，允许调用方控制是否全局报错\n    async function apiFetch(endpoint, options = {}, suppressGlobalError = false) {\n        showLoading();\n        hideError();\n        hideError(categoryQuotasErrorDiv);\n        hideSuccess(); // Hide success message on new request\n\n        // No need for Authorization header, rely on HttpOnly cookie\n        const defaultHeaders = {\n            'Content-Type': 'application/json',\n        };\n\n        try {\n            const response = await fetch(`/api/admin${endpoint}`, {\n                credentials: 'include', \n                ...options,\n                headers: {\n                    ...defaultHeaders,\n                    ...(options.headers || {}),\n                },\n            });\n\n            // Check for auth errors (401 Unauthorized, 403 Forbidden)\n            if (response.status === 401 || response.status === 403) {\n                console.log(\"Authentication required or session expired. Redirecting to login.\");\n                localStorage.removeItem('isLoggedIn');\n                window.location.href = '/login';\n                return null;\n            }\n            \n            // Check for redirects that might indicate auth issues (302, 307, etc.)\n            if (response.redirected) {\n                const redirectUrl = new URL(response.url);\n                // Check if redirected to login page or similar auth pages\n                if (redirectUrl.pathname.includes('login') || \n                    !redirectUrl.pathname.includes('/api/admin')) {\n                    console.log(\"Detected redirect to login page. Session likely expired.\");\n                    localStorage.removeItem('isLoggedIn');\n                    window.location.href = '/login';\n                    return null;\n                }\n            }\n            \n            // Additional check for 3xx status codes\n            if (response.status >= 300 && response.status < 400) {\n                console.log(`Redirect status detected: ${response.status}. Handling potential auth issue.`);\n                localStorage.removeItem('isLoggedIn');\n                window.location.href = '/login';\n                return null;\n            }\n\n            let data = null;\n            const contentType = response.headers.get(\"content-type\");\n            if (contentType && contentType.indexOf(\"application/json\") !== -1) {\n                 try {\n                    data = await response.json();\n                 } catch (e) {\n                    if (response.ok) {\n                        console.warn(\"Received OK response but failed to parse JSON body.\");\n                        return { success: true };\n                    } else {\n                        const errorText = await response.text();\n                        throw new Error(`HTTP error! status: ${response.status} ${response.statusText} - ${errorText}`);\n                    }\n                 }\n            } else if (!response.ok) {\n                 const errorText = await response.text();\n                 throw new Error(`HTTP error! status: ${response.status} ${response.statusText} - ${errorText}`);\n            } else {\n                 console.log(`Received non-JSON response with status ${response.status}`);\n                 return { success: true };\n            }\n\n            // 409 Conflict\n            if (response.status === 409) {\n                throw new Error(data?.error || 'Existing API key');\n            }\n\n            if (!response.ok) {\n                throw new Error(data?.error || `HTTP error! status: ${response.status}`);\n            }\n\n            return data;\n        } catch (error) {\n            console.error('API Fetch Error:', error);\n            if (suppressGlobalError) {\n                throw error;\n            }\n            if (endpoint === '/category-quotas') {\n                showError(error.message || 'An unknown error occurred.', categoryQuotasErrorDiv, categoryQuotasErrorDiv);\n            } else {\n                showError(error.message || 'An unknown error occurred.');\n            }\n            return null;\n        } finally {\n            hideLoading();\n        }\n    }\n\n    // --- Rendering Functions ---\n\n    // Helper to format quota display (Infinity becomes ∞)\n    function formatQuota(quota) {\n        return (quota === undefined || quota === null || quota === Infinity) ? '∞' : quota;\n    }\n\n    // Helper to calculate remaining percentage for progress bar\n    function calculateRemainingPercentage(count, quota) {\n        if (quota === undefined || quota === null || quota === Infinity || quota <= 0) {\n            return 100;\n        }\n        const percentage = Math.max(0, 100 - (count / quota * 100));\n        return percentage;\n    }\n\n    // Helper to get progress bar color based on percentage\n    function getProgressColor(percentage) {\n        if (percentage < 25) return 'bg-red-500';\n        if (percentage < 50) return 'bg-yellow-500';\n        return 'bg-green-500';\n    }\n\n\nasync function renderGeminiKeys(keys) {\n        geminiKeysListDiv.innerHTML = ''; // Clear previous list\n        if (!keys || keys.length === 0) {\n            geminiKeysListDiv.innerHTML = '<p class=\"text-gray-500\">No Gemini keys configured.</p>';\n            // Hide action buttons when no keys\n            geminiKeysActionsDiv.classList.add('hidden');\n            // Disable add model form when no Gemini keys\n            updateAddModelFormState(false);\n            return;\n        }\n\n        // Check if there are any error keys\n        const hasErrorKeys = keys.some(key => key.errorStatus === 400 || key.errorStatus === 401 || key.errorStatus === 403);\n\n        // Show/hide error-related buttons based on error keys existence\n        if (hasErrorKeys) {\n            ignoreAllErrorsBtn.classList.remove('hidden');\n            cleanErrorKeysBtn.classList.remove('hidden');\n        } else {\n            ignoreAllErrorsBtn.classList.add('hidden');\n            cleanErrorKeysBtn.classList.add('hidden');\n        }\n\n        // Show action buttons when keys exist\n        geminiKeysActionsDiv.classList.remove('hidden');\n\n        // Enable add model form when Gemini keys exist\n        updateAddModelFormState(true);\n\n        // Ensure models and category quotas are cached (should be loaded in initialLoad)\n        if (cachedModels.length === 0) {\n            console.warn(\"Models cache is empty during renderGeminiKeys. Load may be incomplete.\");\n        }\n\n        // Calculate statistics\n        const totalKeys = keys.length;\n        const totalUsage = keys.reduce((sum, key) => sum + (parseInt(key.usage) || 0), 0);\n        \n        // Create main container\n        const keysContainer = document.createElement('div');\n        keysContainer.className = 'keys-container';\n        geminiKeysListDiv.appendChild(keysContainer);\n        \n        // Create statistics bar that's always visible\n        const statsBar = document.createElement('div');\n        // Removed justify-between, added relative for positioning context and select-none to prevent text selection\n        statsBar.className = 'stats-bar relative flex items-center justify-center p-3 rounded-md mb-4 cursor-pointer transition-colors select-none';\n        statsBar.innerHTML = `\n            <div class=\"stats-info flex items-center space-x-8\"> \n                 <div class=\"flex items-baseline\">\n                     <span class=\"text-sm font-bold text-gray-600 dark:text-gray-400 mr-1\">API Keys:</span>\n                     <span class=\"text-lg font-semibold text-blue-700 dark:text-blue-400\">${totalKeys}</span>\n                 </div>\n                 <div class=\"flex items-baseline\">\n                     <span class=\"text-sm font-bold text-gray-600 dark:text-gray-400 mr-1\">24hr Usage:</span>\n                     <span class=\"text-lg font-semibold text-green-700 dark:text-green-400\">${totalUsage}</span>\n                 </div>\n            </div>\n            <div class=\"toggle-icon absolute right-3 top-1/2 transform -translate-y-1/2\">\n                <svg class=\"expand-icon w-5 h-5 text-gray-600\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\"></path>\n                </svg>\n        <svg class=\"collapse-icon w-5 h-5 hidden text-gray-600\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 14l-7-7-7 7\"></path>\n        </svg>\n            </div>\n        `;\n        keysContainer.appendChild(statsBar);\n        \n        // Create collapsible grid container with fixed height for responsive design\n        const keysGrid = document.createElement('div');\n        // Mobile: 1 column, show 6 items (6 rows)\n        // Tablet: 2 columns, show 6 items (3 rows)\n        // Desktop: 3 columns, show 9 items (3 rows)\n        keysGrid.className = 'keys-grid grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-all duration-300 overflow-y-auto border border-gray-200 rounded-lg p-2';\n\n        // Function to calculate and apply dynamic height\n        const updateGridHeight = () => {\n            // Each card is 80px height + 16px gap between cards + 8px padding\n            const cardHeight = 80;\n            const gapSize = 16;\n            const containerPadding = 8;\n\n            // Calculate number of rows based on screen size and total keys\n            let columnsCount, maxRows, actualRows;\n\n            // Determine columns and max rows based on screen size\n            if (window.innerWidth >= 1024) { // lg breakpoint\n                columnsCount = 3;\n                maxRows = 3; // Show max 3 rows on desktop\n            } else if (window.innerWidth >= 768) { // md breakpoint\n                columnsCount = 2;\n                maxRows = 3; // Show max 3 rows on tablet\n            } else {\n                columnsCount = 1;\n                maxRows = 6; // Show max 6 rows on mobile\n            }\n\n            // Calculate actual rows needed\n            actualRows = Math.ceil(totalKeys / columnsCount);\n\n            // Use the smaller of actual rows needed or max rows allowed\n            const displayRows = Math.min(actualRows, maxRows);\n\n            // Calculate dynamic height: rows * cardHeight + (rows-1) * gap + padding\n            const dynamicHeight = displayRows * cardHeight + (displayRows - 1) * gapSize + containerPadding * 2;\n            const maxHeight = maxRows * cardHeight + (maxRows - 1) * gapSize + containerPadding * 2;\n\n            // Apply dynamic height with max height constraint\n            keysGrid.style.height = `${dynamicHeight}px`;\n            keysGrid.style.maxHeight = `${maxHeight}px`;\n        };\n\n        // Initial height calculation\n        updateGridHeight();\n\n        // Add resize listener to recalculate height when window size changes\n        const resizeHandler = () => updateGridHeight();\n        window.addEventListener('resize', resizeHandler);\n\n        // Store the resize handler for cleanup (optional)\n        keysGrid._resizeHandler = resizeHandler;\n        \n        // Set initial expanded/collapsed state based on key count\n        // With fixed height containers, we can be more generous with initial expansion\n        // Mobile: show if <= 6 keys, Desktop: show if <= 9 keys\n        const isInitiallyExpanded = totalKeys <= 9;\n        if (!isInitiallyExpanded) {\n            keysGrid.classList.add('hidden');\n            // Hide action buttons when grid is initially collapsed\n            geminiKeysActionsDiv.classList.add('hidden');\n        }\n        \n        // Update icon display\n        const expandIcon = statsBar.querySelector('.expand-icon'); // Left arrow - collapsed state\n        const collapseIcon = statsBar.querySelector('.collapse-icon'); // Down arrow - expanded state\n\n        if (isInitiallyExpanded) {\n            // Content expanded state (visible): show down arrow\n            expandIcon.classList.add('hidden');\n            collapseIcon.classList.remove('hidden');\n        } else {\n            // Content collapsed state (hidden): show left arrow\n            expandIcon.classList.remove('hidden');\n            collapseIcon.classList.add('hidden');\n        }\n        \n        keysContainer.appendChild(keysGrid);\n\n        // Add click event listener to toggle the grid visibility\n        statsBar.addEventListener('click', (e) => {\n            // 防止文本选择\n            e.preventDefault();\n            const isCurrentlyHidden = keysGrid.classList.contains('hidden');\n            keysGrid.classList.toggle('hidden');\n            expandIcon.classList.toggle('hidden');\n            collapseIcon.classList.toggle('hidden');\n\n            // Toggle action buttons visibility based on grid visibility\n            if (isCurrentlyHidden) {\n                // Grid is being shown, show action buttons\n                geminiKeysActionsDiv.classList.remove('hidden');\n            } else {\n                // Grid is being hidden, hide action buttons\n                geminiKeysActionsDiv.classList.add('hidden');\n            }\n        });\n\n        keys.forEach(key => {\n            // Create a simplified card for each key with optimized height\n            const cardItem = document.createElement('div');\n            cardItem.className = 'card-item p-3 border rounded-md bg-white shadow-sm hover:shadow-md transition-shadow cursor-pointer select-none h-[80px] flex flex-col justify-between';\n            cardItem.dataset.keyId = key.id;\n\n            // Show warning icon or usage badge\n            let rightSideContent = '';\n            if (key.errorStatus === 400 || key.errorStatus === 401 || key.errorStatus === 403) {\n                rightSideContent = `\n                    <div class=\"warning-icon-container flex items-center justify-center\">\n                        <svg class=\"w-4 h-4 text-yellow-500 warning-icon\" fill=\"currentColor\" viewBox=\"0 0 20 20\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <path fill-rule=\"evenodd\" d=\"M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 3.001-1.742 3.001H4.42c-1.53 0-2.493-1.667-1.743-3.001l5.58-9.92zM10 13a1 1 0 110-2 1 1 0 010 2zm0-8a1 1 0 011 1v3a1 1 0 11-2 0V6a1 1 0 011-1z\" clip-rule=\"evenodd\"></path>\n                        </svg>\n                    </div>\n                `;\n            } else {\n                rightSideContent = `\n                    <div class=\"text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full whitespace-nowrap\">\n                        ${key.usage}\n                    </div>\n                `;\n            }\n\n            // Optimized card content with better spacing and typography\n            cardItem.innerHTML = `\n                <div class=\"flex items-start justify-between h-full\">\n                    <div class=\"flex-1 min-w-0 pr-2\">\n                        <h3 class=\"font-medium text-sm text-gray-900 truncate mb-1\">${key.name || key.id}</h3>\n                        <p class=\"text-xs text-gray-500 truncate\">ID: ${key.id}</p>\n                        <p class=\"text-xs text-gray-400 truncate\">${key.keyPreview}</p>\n                    </div>\n                    <div class=\"flex-shrink-0\">\n                        ${rightSideContent}\n                    </div>\n                </div>\n            `;\n\n\n            keysGrid.appendChild(cardItem);\n\n            // Create a hidden detailed information modal\n            const detailModal = document.createElement('div');\n            detailModal.className = 'fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50 hidden';\n            detailModal.dataset.modalFor = key.id;\n\n            // --- Start Modal HTML ---\n            let modalHTML = `\n                <div class=\"modal-content bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto mx-4\">\n                    <div class=\"flex justify-between items-center mb-4\">\n                        <h2 class=\"text-xl font-bold text-gray-800\">${key.name || key.id}</h2>\n                        <button class=\"close-modal text-gray-500 hover:text-gray-800\">\n                            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                            </svg>\n                        </button>\n                    </div>\n                    <div class=\"grid grid-cols-2 gap-4 mb-4\">\n                        <div>\n                            <p class=\"text-sm text-gray-600\">${t('id')}: ${key.id}</p>\n                            <p class=\"text-sm text-gray-600\">${t('key_preview')}: ${key.keyPreview}</p>\n                        </div>\n                        <div>\n                            <p class=\"text-sm text-gray-600\">${t('total_usage_today')}: ${key.usage}</p>\n                            <p class=\"text-sm text-gray-600\">${t('date')}: ${key.usageDate}</p>\n                            ${key.errorStatus ? `<p class=\"text-sm text-red-600 font-medium\">${t('error_status')}: ${key.errorStatus}</p>` : ''}\n                        </div>\n                    </div>\n                    <div class=\"flex justify-end space-x-2 mb-4\">\n                        ${key.errorStatus ? `<button data-id=\"${key.id}\" class=\"clear-gemini-key-error text-yellow-600 hover:text-yellow-800 font-medium px-3 py-1 border border-yellow-600 rounded\">${t('ignore_error')}</button>` : ''}\n                        <button data-id=\"${key.id}\" class=\"test-gemini-key text-blue-500 hover:text-blue-700 font-medium px-3 py-1 border border-blue-500 rounded\">${t('test')}</button>\n                        <button data-id=\"${key.id}\" class=\"delete-gemini-key text-red-500 hover:text-red-700 font-medium px-3 py-1 border border-red-500 rounded\">${t('delete')}</button>\n                    </div>\n                    <!-- Container for error messages within the modal -->\n                    <div id=\"gemini-key-error-container-${key.id}\" class=\"hidden bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded relative mb-4\" role=\"alert\">\n                        <span class=\"block sm:inline\"></span>\n                    </div>\n\n                    <!-- Category Usage Section -->\n                    <div class=\"border-t border-gray-200 pt-4 mb-4\">\n                        <h3 class=\"text-lg font-medium text-gray-800 mb-3\">${t('category_usage')}</h3>\n                        <div class=\"space-y-4\">\n            `;\n\n            // Pro Category Usage\n            const proUsage = key.categoryUsage?.pro || 0;\n            const proQuota = cachedCategoryQuotas.proQuota;\n            const proQuotaDisplay = formatQuota(proQuota);\n            const proRemaining = proQuota === Infinity ? Infinity : Math.max(0, proQuota - proUsage);\n            const proRemainingDisplay = formatQuota(proRemaining);\n            const proRemainingPercentage = calculateRemainingPercentage(proUsage, proQuota);\n            const proProgressColor = getProgressColor(proRemainingPercentage);\n\n            modalHTML += `\n                <div>\n                    <div class=\"flex justify-between mb-1\">\n                        <span class=\"text-sm font-medium text-gray-700\">${t('pro_models')}</span>\n                        <span class=\"text-sm font-medium text-gray-700\">${proRemainingDisplay}/${proQuotaDisplay}</span>\n                    </div>\n                    <div class=\"w-full bg-gray-200 rounded-full h-2.5\">\n                        <div class=\"${proProgressColor} h-2.5 rounded-full\" style=\"width: ${proRemainingPercentage}%\"></div>\n                    </div>\n                </div>\n            `;\n\n            // Handle Pro category individual quota models\n            const proModelsWithIndividualQuota = cachedModels.filter(model => \n                model.category === 'Pro' && \n                model.individualQuota && \n                key.modelUsage && \n                key.modelUsage[model.id] !== undefined\n            );\n\n            if (proModelsWithIndividualQuota.length > 0) {\n            proModelsWithIndividualQuota.forEach(model => {\n                    const modelId = model.id;\n                    // Check if it's an object structure, if so, extract the count property\n                    const count = typeof key.modelUsage?.[modelId] === 'object' ? \n                        (key.modelUsage?.[modelId]?.count || 0) : \n                        (key.modelUsage?.[modelId] || 0);\n                    const quota = model.individualQuota;\n                    const quotaDisplay = formatQuota(quota);\n                    const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);\n                    const remainingDisplay = formatQuota(remaining);\n                    const remainingPercentage = calculateRemainingPercentage(count, quota);\n                    const progressColor = getProgressColor(remainingPercentage);\n\n                    modalHTML += `\n                        <div class=\"mt-2\">\n                            <div class=\"flex justify-between mb-1\">\n                                <span class=\"text-sm font-medium text-gray-700\">${modelId}</span>\n                                <span class=\"text-sm font-medium text-gray-700\">${remainingDisplay}/${quotaDisplay}</span>\n                            </div>\n                            <div class=\"w-full bg-gray-200 rounded-full h-2.5\">\n                                <div class=\"${progressColor} h-2.5 rounded-full\" style=\"width: ${remainingPercentage}%\"></div>\n                            </div>\n                        </div>\n                    `;\n                });\n            }\n\n            // Flash Category Usage\n            const flashUsage = key.categoryUsage?.flash || 0;\n            const flashQuota = cachedCategoryQuotas.flashQuota;\n            const flashQuotaDisplay = formatQuota(flashQuota);\n            const flashRemaining = flashQuota === Infinity ? Infinity : Math.max(0, flashQuota - flashUsage);\n            const flashRemainingDisplay = formatQuota(flashRemaining);\n            const flashRemainingPercentage = calculateRemainingPercentage(flashUsage, flashQuota);\n            const flashProgressColor = getProgressColor(flashRemainingPercentage);\n\n            modalHTML += `\n                <div class=\"mt-2\">\n                    <div class=\"flex justify-between mb-1\">\n                        <span class=\"text-sm font-medium text-gray-700\">${t('flash_models')}</span>\n                        <span class=\"text-sm font-medium text-gray-700\">${flashRemainingDisplay}/${flashQuotaDisplay}</span>\n                    </div>\n                    <div class=\"w-full bg-gray-200 rounded-full h-2.5\">\n                        <div class=\"${flashProgressColor} h-2.5 rounded-full\" style=\"width: ${flashRemainingPercentage}%\"></div>\n                    </div>\n                </div>\n            `;\n            \n            // Handle Flash category individual quota models\n            const flashModelsWithIndividualQuota = cachedModels.filter(model => \n                model.category === 'Flash' && \n                model.individualQuota && \n                key.modelUsage && \n                key.modelUsage[model.id] !== undefined\n            );\n\n            if (flashModelsWithIndividualQuota.length > 0) {\n                flashModelsWithIndividualQuota.forEach(model => {\n                    const modelId = model.id;\n                    // Check if it's an object structure, if so, extract the count property\n                    const count = typeof key.modelUsage?.[modelId] === 'object' ? \n                        (key.modelUsage?.[modelId]?.count || 0) : \n                        (key.modelUsage?.[modelId] || 0);\n                    const quota = model.individualQuota;\n                    const quotaDisplay = formatQuota(quota);\n                    const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);\n                    const remainingDisplay = formatQuota(remaining);\n                    const remainingPercentage = calculateRemainingPercentage(count, quota);\n                    const progressColor = getProgressColor(remainingPercentage);\n\n                    modalHTML += `\n                        <div class=\"mt-2\">\n                            <div class=\"flex justify-between mb-1\">\n                                <span class=\"text-sm font-medium text-gray-700\">${modelId}</span>\n                                <span class=\"text-sm font-medium text-gray-700\">${remainingDisplay}/${quotaDisplay}</span>\n                            </div>\n                            <div class=\"w-full bg-gray-200 rounded-full h-2.5\">\n                                <div class=\"${progressColor} h-2.5 rounded-full\" style=\"width: ${remainingPercentage}%\"></div>\n                            </div>\n                        </div>\n                    `;\n                });\n            }\n\n            modalHTML += `\n                        </div>\n                    </div>\n            `;\n\n            // Custom Model Usage Section (Only if there are custom models used by this key)\n            const customModelUsageEntries = Object.entries(key.modelUsage || {})\n                .filter(([modelId, usageData]) => {\n                    const model = cachedModels.find(m => m.id === modelId);\n                    return model?.category === 'Custom';\n                });\n\n            if (customModelUsageEntries.length > 0) {\n                modalHTML += `\n                    <div class=\"border-t border-gray-200 pt-4 mb-4\">\n                        <h3 class=\"text-lg font-medium text-gray-800 mb-3\">Custom Model Usage</h3>\n                        <div class=\"space-y-4\">\n                `;\n\n                customModelUsageEntries.forEach(([modelId, usageData]) => {\n                    // Ensure count is obtained correctly, regardless of object structure\n                    const count = typeof usageData === 'object' ? \n                        (usageData.count || 0) : (usageData || 0);\n                    const quota = typeof usageData === 'object' ? \n                        usageData.quota : undefined; // Quota is now included in the key data for custom models\n                    const quotaDisplay = formatQuota(quota);\n                    const remaining = quota === Infinity ? Infinity : Math.max(0, quota - count);\n                    const remainingDisplay = formatQuota(remaining);\n                    const remainingPercentage = calculateRemainingPercentage(count, quota);\n                    const progressColor = getProgressColor(remainingPercentage);\n\n                    modalHTML += `\n                        <div>\n                            <div class=\"flex justify-between mb-1\">\n                                <span class=\"text-sm font-medium text-gray-700\">${modelId}</span>\n                                <span class=\"text-sm font-medium text-gray-700\">${remainingDisplay}/${quotaDisplay}</span>\n                            </div>\n                            <div class=\"w-full bg-gray-200 rounded-full h-2.5\">\n                                <div class=\"${progressColor} h-2.5 rounded-full\" style=\"width: ${remainingPercentage}%\"></div>\n                            </div>\n                        </div>\n                    `;\n                });\n\n                modalHTML += `\n                        </div>\n                    </div>\n                `;\n            }\n\n\n            // Add test section (remains mostly the same, uses cachedModels)\n            modalHTML += `\n                <div class=\"test-model-section mt-3 border-t pt-4 hidden\" data-key-id=\"${key.id}\">\n                    <h3 class=\"text-lg font-medium text-gray-800 mb-2\">${t('test_api_key')}</h3>\n                    <div class=\"flex items-center\">\n                        <select class=\"model-select mr-2 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\">\n                            <option value=\"\">${t('select_a_model')}</option>\n                            ${cachedModels.map(model => `<option value=\"${model.id}\">${model.id}</option>`).join('')}\n                        </select>\n                        <button class=\"run-test-btn inline-flex justify-center py-1 px-3 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500\">\n                            ${t('run_test')}\n                        </button>\n                    </div>\n                    <div class=\"test-result mt-2 hidden\">\n                        <pre class=\"text-xs bg-gray-100 p-2 rounded overflow-x-auto\"></pre>\n                    </div>\n                </div>\n            `;\n\n            // Close modal div\n            modalHTML += `</div>`;\n            // --- End Modal HTML ---\n\n            detailModal.innerHTML = modalHTML;\n            document.body.appendChild(detailModal);\n\n\n            // Add click event to the card to display the detailed information modal\n            cardItem.addEventListener('click', (e) => {\n                // 防止文本选择\n                e.preventDefault();\n                detailModal.classList.remove('hidden');\n            });\n\n            // Add event to the close button\n            const closeBtn = detailModal.querySelector('.close-modal');\n            closeBtn.addEventListener('click', () => {\n                detailModal.classList.add('hidden');\n            });\n\n            // Close by clicking outside the modal\n            detailModal.addEventListener('click', (e) => {\n                if (e.target === detailModal) {\n                    detailModal.classList.add('hidden');\n                }\n            });\n        });\n\n        // Note: Event listeners for .test-gemini-key and .run-test-btn are now handled\n        // by global event delegation to prevent duplicate listeners and DOM reference issues\n    }\n\n    function renderWorkerKeys(keys) {\n        workerKeysListDiv.innerHTML = ''; // Clear previous list\n        if (!keys || keys.length === 0) {\n            workerKeysListDiv.innerHTML = '<p class=\"text-gray-500\">No Worker keys configured.</p>';\n            return;\n        }\n\n        keys.forEach(key => {\n            const isSafetyEnabled = key.safetyEnabled !== undefined ? key.safetyEnabled : true;\n\n            const item = document.createElement('div');\n            item.className = 'p-3 border rounded-md';\n            item.innerHTML = `\n                <div class=\"flex items-center justify-between mb-2\">\n                    <div>\n                        <p class=\"font-mono text-sm text-gray-700\">${key.key}</p>\n                        <p class=\"text-xs text-gray-500\">${key.description || t('no_description')} (${t('created')}: ${new Date(key.createdAt).toLocaleDateString()})</p>\n                    </div>\n                    <button data-key=\"${key.key}\" class=\"delete-worker-key text-red-500 hover:text-red-700 font-medium\">${t('delete')}</button>\n                </div>\n                <div class=\"flex items-center mt-2 border-t pt-2\">\n                    <div class=\"flex items-center\">\n                        <label for=\"safety-toggle-${key.key}\" class=\"text-sm font-medium text-gray-700 mr-2\">${t('safety_settings')}:</label>\n                        <div class=\"relative inline-block w-10 mr-2 align-middle select-none\">\n                            <input type=\"checkbox\" id=\"safety-toggle-${key.key}\"\n                                data-key=\"${key.key}\"\n                                class=\"safety-toggle toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer transition-transform duration-200 ease-in-out\"\n                                ${isSafetyEnabled ? 'checked' : ''}\n                            />\n                            <label for=\"safety-toggle-${key.key}\"\n                                class=\"toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer\"\n                            ></label>\n                        </div>\n                        <span class=\"text-xs font-medium ${isSafetyEnabled ? 'text-green-600' : 'text-red-600'}\">\n                            ${isSafetyEnabled ? t('enabled') : t('disabled')}\n                        </span>\n                    </div>\n                </div>\n            `;\n\n            workerKeysListDiv.appendChild(item);\n        });\n\n        // Add styles for toggle switch\n        const style = document.createElement('style');\n        style.textContent = `\n            .toggle-checkbox:checked {\n                /* Adjusted translation to keep handle within bounds */\n                transform: translateX(1rem); /* Was 100% */\n                border-color: #68D391;\n            }\n            .toggle-checkbox:checked + .toggle-label {\n                background-color: #68D391;\n            }\n            .toggle-label {\n                transition: background-color 0.2s ease-in-out;\n            }\n        `;\n        document.head.appendChild(style);\n\n        // Add event listeners for safety toggles\n        document.querySelectorAll('.safety-toggle').forEach(toggle => {\n            toggle.addEventListener('change', function() {\n                const key = this.dataset.key;\n                const isEnabled = this.checked;\n                const statusText = this.parentElement.nextElementSibling;\n                statusText.textContent = isEnabled ? t('enabled') : t('disabled');\n                statusText.className = `text-xs font-medium ${isEnabled ? 'text-green-600' : 'text-red-600'}`;\n                saveSafetySettingsToServer(key, isEnabled);\n\n                console.log(`Safety settings for key ${key} set to ${isEnabled ? 'enabled' : 'disabled'}`);\n            });\n        });\n    }\n\n     function renderModels(models) {\n        modelsListDiv.innerHTML = ''; // Clear previous list\n         if (!models || models.length === 0) {\n            modelsListDiv.innerHTML = '<p class=\"text-gray-500\">No models configured.</p>';\n            return;\n        }\n        models.forEach(model => {\n            const item = document.createElement('div');\n            item.className = 'p-3 border rounded-md flex items-center justify-between';\n            \n            let quotaDisplay = model.category;\n            if (model.category === 'Custom') {\n                quotaDisplay += ` (${t('quota')}: ${model.dailyQuota === undefined ? t('unlimited') : model.dailyQuota})`;\n            } else if (model.individualQuota) {\n                // Show individual quota if it exists for Pro/Flash models\n                quotaDisplay += ` (${t('individual_quota')}: ${model.individualQuota})`;\n            }\n\n            let actionsHtml = '';\n            // Only show Set Individual Quota button for Pro and Flash models\n            if (model.category === 'Pro' || model.category === 'Flash') {\n                actionsHtml = `\n                    <button data-id=\"${model.id}\" data-category=\"${model.category}\" data-quota=\"${model.individualQuota || 0}\"\n                        class=\"set-individual-quota mr-2 text-blue-500 hover:text-blue-700 font-medium\">\n                        ${t('set_quota_btn')}\n                    </button>\n                `;\n            }\n            actionsHtml += `<button data-id=\"${model.id}\" class=\"delete-model text-red-500 hover:text-red-700 font-medium\">${t('delete')}</button>`;\n\n            item.innerHTML = `\n                <div>\n                    <p class=\"font-semibold text-gray-800\">${model.id}</p>\n                    <p class=\"text-xs text-gray-500\">${quotaDisplay}</p>\n                </div>\n                <div class=\"flex items-center\">\n                    ${actionsHtml}\n                </div>\n            `;\n            modelsListDiv.appendChild(item);\n        });\n\n        // Add event listeners for individual quota buttons\n        document.querySelectorAll('.set-individual-quota').forEach(btn => {\n            btn.addEventListener('click', (e) => {\n                const modelId = e.target.dataset.id;\n                const category = e.target.dataset.category;\n                const currentQuota = parseInt(e.target.dataset.quota, 10);\n                \n                // Set the form values\n                individualQuotaModelIdInput.value = modelId;\n                individualQuotaValueInput.value = currentQuota || 0;\n                \n                // Show the modal\n                hideError(individualQuotaErrorDiv);\n                individualQuotaModal.classList.remove('hidden');\n            });\n        });\n    }\n\n    // --- Data Loading Functions ---\n    async function loadGeminiKeys() {\n        const keys = await apiFetch('/gemini-keys');\n        if (keys) {\n            renderGeminiKeys(keys);\n        } else {\n             geminiKeysListDiv.innerHTML = '<p class=\"text-red-500\">Failed to load Gemini keys.</p>';\n             // Disable add model form when failed to load keys\n             updateAddModelFormState(false);\n        }\n    }\n\n    async function loadWorkerKeys() {\n        const keys = await apiFetch('/worker-keys');\n        if (keys) {\n            renderWorkerKeys(keys);\n        } else {\n             workerKeysListDiv.innerHTML = '<p class=\"text-red-500\">Failed to load Worker keys.</p>';\n        }\n    }\n\n    async function loadModels() {\n        const models = await apiFetch('/models');\n        if (models) {\n            cachedModels = models;\n            renderModels(models);\n        } else {\n             modelsListDiv.innerHTML = '<p class=\"text-red-500\">Failed to load models.</p>';\n        }\n    }\n\n    // New function to load category quotas\n    async function loadCategoryQuotas() {\n        const quotas = await apiFetch('/category-quotas');\n        if (quotas) {\n            cachedCategoryQuotas = quotas;\n        } else {\n            showError(\"Failed to load category quotas.\");\n        }\n        return quotas;\n    }\n\n    // New function to load available Gemini models\n    async function loadGeminiAvailableModels(forceRefresh = false) {\n        // Only proceed if we have Gemini keys\n        const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');\n        if (geminiKeysList.length === 0) {\n            console.log(\"No Gemini keys available, skipping model list fetch\");\n            // Clear cached models if no keys available\n            cachedGeminiModels = [];\n            return;\n        }\n\n        // Skip if we already have cached models and not forcing refresh\n        if (!forceRefresh && cachedGeminiModels.length > 0) {\n            console.log(\"Using cached Gemini models\");\n            updateModelIdDropdown(cachedGeminiModels);\n            return;\n        }\n\n        try {\n            console.log(\"Fetching available Gemini models from server...\");\n            const models = await apiFetch('/gemini-models');\n            if (models && Array.isArray(models)) {\n                cachedGeminiModels = models;\n\n                // Update the model-id input field to include dropdown\n                updateModelIdDropdown(models);\n\n                console.log(`Loaded ${models.length} available Gemini models`);\n            } else {\n                console.warn(\"No models returned from server\");\n                cachedGeminiModels = [];\n            }\n        } catch (error) {\n            console.error(\"Failed to load Gemini models:\", error);\n            cachedGeminiModels = [];\n        }\n    }\n\n    // Update the model-id input to include dropdown functionality\n    function updateModelIdDropdown(models) {\n        if (!modelIdInput) return;\n        \n        // Create custom dropdown menu\n        const createCustomDropdown = () => {\n            // Remove old dropdown menu (if it exists)\n            const existingDropdown = document.getElementById('custom-model-dropdown');\n            if (existingDropdown) {\n                existingDropdown.remove();\n            }\n            \n            // Create new dropdown menu container\n            const dropdownContainer = document.createElement('div');\n            dropdownContainer.id = 'custom-model-dropdown';\n            dropdownContainer.className = 'absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base overflow-auto focus:outline-none sm:text-sm hidden';\n            dropdownContainer.style.maxHeight = '200px';\n            dropdownContainer.style.overflowY = 'auto';\n            dropdownContainer.style.border = '1px solid #d1d5db';\n            \n            // Add model options to the dropdown menu\n            models.forEach(model => {\n                const option = document.createElement('div');\n                option.className = 'cursor-pointer select-none relative py-2 pl-3 pr-9 hover:bg-gray-100';\n                option.textContent = model.id;\n                option.dataset.value = model.id;\n                \n                option.addEventListener('click', () => {\n                    modelIdInput.value = model.id;\n                    dropdownContainer.classList.add('hidden');\n                    \n                    // Automatically select category based on model name\n                    const modelValue = model.id.toLowerCase();\n                    if (modelValue.includes('pro')) {\n                        modelCategorySelect.value = 'Pro';\n                        customQuotaDiv.classList.add('hidden');\n                        modelQuotaInput.required = false;\n                    } else if (modelValue.includes('flash')) {\n                        modelCategorySelect.value = 'Flash';\n                        customQuotaDiv.classList.add('hidden');\n                        modelQuotaInput.required = false;\n                    }\n                    \n                    // Trigger input event so other listeners can respond\n                    modelIdInput.dispatchEvent(new Event('input'));\n                });\n                \n                dropdownContainer.appendChild(option);\n            });\n            \n            // Add the dropdown menu to the input element's parent\n            modelIdInput.parentNode.appendChild(dropdownContainer);\n            \n            return dropdownContainer;\n        };\n        \n        // Create dropdown menu\n        const dropdown = createCustomDropdown();\n        console.log(`Created custom dropdown with ${models.length} model options`);\n        \n        // Add input event to automatically select category based on model name and filter dropdown options\n        modelIdInput.addEventListener('input', function() {\n            const modelValue = this.value.toLowerCase();\n            \n            // Filter dropdown options based on input value\n            const options = dropdown.querySelectorAll('div[data-value]');\n            let hasVisibleOptions = false;\n            \n            options.forEach(option => {\n                const optionValue = option.dataset.value.toLowerCase();\n                if (optionValue.includes(modelValue)) {\n                    option.style.display = 'block';\n                    hasVisibleOptions = true;\n                } else {\n                    option.style.display = 'none';\n                }\n            });\n            \n            // If there are matching options, show the dropdown menu\n            if (hasVisibleOptions && modelValue) {\n                dropdown.classList.remove('hidden');\n            } else {\n                dropdown.classList.add('hidden');\n            }\n            \n            // Automatically select category based on input value\n            if (modelValue.includes('pro')) {\n                modelCategorySelect.value = 'Pro';\n                customQuotaDiv.classList.add('hidden');\n                modelQuotaInput.required = false;\n            } else if (modelValue.includes('flash')) {\n                modelCategorySelect.value = 'Flash';\n                customQuotaDiv.classList.add('hidden');\n                modelQuotaInput.required = false;\n            }\n        });\n        \n        // Add click event to show the dropdown menu and refresh models if needed\n        modelIdInput.addEventListener('click', async function() {\n            // Check if we need to refresh the model list\n            const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');\n            if (geminiKeysList.length > 0 && (cachedGeminiModels.length === 0 || models.length === 0)) {\n                console.log(\"Refreshing Gemini models list on input click...\");\n                await loadGeminiAvailableModels();\n                return; // loadGeminiAvailableModels will recreate the dropdown with updated models\n            }\n\n            if (models.length > 0) {\n                // Show all options\n                const options = dropdown.querySelectorAll('div[data-value]');\n                options.forEach(option => {\n                    option.style.display = 'block';\n                });\n\n                dropdown.classList.remove('hidden');\n            }\n        });\n        \n        // Hide dropdown menu when clicking elsewhere on the page\n        document.addEventListener('click', function(e) {\n            if (e.target !== modelIdInput && !dropdown.contains(e.target)) {\n                dropdown.classList.add('hidden');\n            }\n        });\n        \n        // Remove datalist attribute from input if it exists\n        modelIdInput.removeAttribute('list');\n    }\n\n\n    // --- Event Handlers ---\n\n    // Add Gemini Key with support for batch input\n    addGeminiKeyForm.addEventListener('submit', async (e) => {\n        e.preventDefault();\n\n        // Prevent concurrent operations\n        if (operationInProgress) {\n            showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');\n            return;\n        }\n\n        const formData = new FormData(addGeminiKeyForm);\n        const data = Object.fromEntries(formData.entries());\n        const geminiKeyInput = data.key ? data.key.trim() : '';\n\n        // Check if input is empty\n        if (!geminiKeyInput) {\n             showError(\"API Key Value is required.\");\n             return;\n        }\n\n        // Split input, supporting comma-separated keys (both English and Chinese commas) and line-separated keys\n        const geminiKeys = geminiKeyInput\n            .split(/[,，\\n\\r]+/)  // Split by English comma, Chinese comma, newline, or carriage return\n            .map(key => key.trim())\n            .filter(key => key !== '');\n        \n        // Check if there are any keys to process\n        if (geminiKeys.length === 0) {\n            showError(\"No valid API Keys found.\");\n            return;\n        }\n\n        // Gemini API Key format validation regex\n        const geminiKeyRegex = /^AIzaSy[A-Za-z0-9_-]{33}$/;\n        \n        // Check format and remove duplicates\n        const validKeys = [];\n        const invalidKeys = [];\n        const seenKeys = new Set();\n        \n        for (const key of geminiKeys) {\n            // Skip duplicates\n            if (seenKeys.has(key)) {\n                continue;\n            }\n            \n            seenKeys.add(key);\n            \n            // Validate format\n            if (!geminiKeyRegex.test(key)) {\n                invalidKeys.push(key);\n            } else {\n                validKeys.push(key);\n            }\n        }\n        \n        // If no valid keys, exit\n        if (validKeys.length === 0) {\n            showError(\"No valid API Keys found. Please check the format.\");\n            return;\n        }\n        \n        // Show warnings about invalid keys but continue with valid ones\n        if (invalidKeys.length > 0) {\n            const maskedInvalidKeys = invalidKeys.map(key => {\n                if (key.length > 10) {\n                    return `${key.substring(0, 6)}...${key.substring(key.length - 4)}`;\n                }\n                return key;\n            });\n            showError(`Invalid API key format detected: ${maskedInvalidKeys.join(', ')}`);\n        }\n        \n        operationInProgress = true; // Lock operations\n        showLoading();\n        let successCount = 0;\n        let failureCount = 0;\n\n        try {\n            if (validKeys.length === 1) {\n                // Single key - use original API with name support\n                let keyData = { key: validKeys[0] };\n                if (data.name) {\n                    keyData.name = data.name.trim();\n                }\n\n                const result = await apiFetch('/gemini-keys', {\n                    method: 'POST',\n                    body: JSON.stringify(keyData),\n                });\n\n                if (result && result.success) {\n                    successCount = 1;\n                    failureCount = 0;\n                } else {\n                    successCount = 0;\n                    failureCount = 1;\n                }\n            } else if (validKeys.length <= 50) {\n                // Medium batch - use single batch API call\n                const result = await apiFetch('/gemini-keys/batch', {\n                    method: 'POST',\n                    body: JSON.stringify({ keys: validKeys }),\n                });\n\n                if (result && result.success) {\n                    successCount = result.successCount || 0;\n                    failureCount = result.failureCount || 0;\n\n                    // Log detailed results for debugging\n                    if (result.results && result.results.length > 0) {\n                        const failures = result.results.filter(r => !r.success);\n                        if (failures.length > 0) {\n                            console.warn('Some keys failed to add:', failures);\n                        }\n                    }\n                } else {\n                    successCount = 0;\n                    failureCount = validKeys.length;\n                }\n            } else {\n                // Large batch - split into chunks and process with limited concurrency\n                const chunkSize = 20; // Process 20 keys per chunk\n                const maxConcurrency = 3; // Maximum 3 concurrent requests\n\n                // Split keys into chunks\n                const chunks = [];\n                for (let i = 0; i < validKeys.length; i += chunkSize) {\n                    chunks.push(validKeys.slice(i, i + chunkSize));\n                }\n\n                // Process chunks with limited concurrency and progress feedback\n                for (let i = 0; i < chunks.length; i += maxConcurrency) {\n                    const currentChunks = chunks.slice(i, i + maxConcurrency);\n\n                    // Show progress\n                    const processedChunks = Math.floor(i / maxConcurrency) + 1;\n                    const totalChunks = Math.ceil(chunks.length / maxConcurrency);\n                    console.log(`Processing batch ${processedChunks}/${totalChunks} (${validKeys.length} total keys)`);\n\n                    // Process current batch of chunks concurrently\n                    const chunkPromises = currentChunks.map((chunk, chunkIndex) =>\n                        apiFetch('/gemini-keys/batch', {\n                            method: 'POST',\n                            body: JSON.stringify({ keys: chunk }),\n                        }).then(result => {\n                            console.log(`Chunk ${i + chunkIndex + 1} completed: ${result?.successCount || 0} success, ${result?.failureCount || 0} failed`);\n                            return result;\n                        }).catch(error => {\n                            console.error(`Error in chunk ${i + chunkIndex + 1} processing:`, error);\n                            return { success: false, successCount: 0, failureCount: chunk.length };\n                        })\n                    );\n\n                    const chunkResults = await Promise.all(chunkPromises);\n\n                    // Aggregate results\n                    chunkResults.forEach(result => {\n                        if (result && result.success) {\n                            successCount += result.successCount || 0;\n                            failureCount += result.failureCount || 0;\n                        } else {\n                            // If the entire chunk failed, count all keys as failures\n                            const chunkIndex = chunkResults.indexOf(result);\n                            const chunkSize = currentChunks[chunkIndex]?.length || 0;\n                            failureCount += chunkSize;\n                        }\n                    });\n\n                    // Small delay between batches to avoid overwhelming the server\n                    if (i + maxConcurrency < chunks.length) {\n                        await new Promise(resolve => setTimeout(resolve, 200));\n                    }\n                }\n            }\n        } catch (error) {\n            console.error('Error during batch add:', error);\n            successCount = 0;\n            failureCount = validKeys.length;\n        } finally {\n            operationInProgress = false; // Release lock\n            hideLoading();\n        }\n\n        // Reset form and reload keys\n        addGeminiKeyForm.reset();\n        await loadGeminiKeys();\n\n        // If keys were successfully added, refresh the available models list\n        if (successCount > 0) {\n            await loadGeminiAvailableModels(true); // Force refresh after adding new keys\n        }\n\n        // Show appropriate message based on results\n        if (successCount > 0) {\n            showSuccess(`Successfully added ${successCount} Gemini ${successCount === 1 ? 'key' : 'keys'}.`);\n        } else {\n            showError(`Failed to add any keys.`);\n        }\n    });\n\n    // Global event delegation for Gemini key actions\n    document.addEventListener('click', async (e) => {\n        // Handle test gemini key button clicks\n        if (e.target.classList.contains('test-gemini-key')) {\n            const keyId = e.target.dataset.id;\n            const testSection = document.querySelector(`.test-model-section[data-key-id=\"${keyId}\"]`);\n\n            // Check if testSection exists (防止DOM重新渲染后元素不存在的错误)\n            if (!testSection) {\n                console.warn('Test section not found for keyId:', keyId);\n                return;\n            }\n\n            // Toggle display status\n            if (testSection.classList.contains('hidden')) {\n                // Hide all other test areas\n                document.querySelectorAll('.test-model-section').forEach(section => {\n                    section.classList.add('hidden');\n                    section.querySelector('.test-result')?.classList.add('hidden');\n                });\n\n                // Show current test area\n                testSection.classList.remove('hidden');\n            } else {\n                testSection.classList.add('hidden');\n            }\n            return;\n        }\n\n        // Handle run test button clicks\n        if (e.target.classList.contains('run-test-btn') && !e.target.id) { // Exclude the main \"run all test\" button\n            const testSection = e.target.closest('.test-model-section');\n\n            // Check if testSection exists (防止DOM重新渲染后元素不存在的错误)\n            if (!testSection) {\n                console.warn('Test section not found, possibly due to DOM re-rendering');\n                return;\n            }\n\n            const keyId = testSection.dataset.keyId;\n            const modelSelect = testSection.querySelector('.model-select');\n            const resultDiv = testSection.querySelector('.test-result');\n            const resultPre = resultDiv?.querySelector('pre');\n\n            // Additional safety checks\n            if (!keyId || !modelSelect || !resultDiv || !resultPre) {\n                console.warn('Required elements not found in test section');\n                return;\n            }\n\n            const modelId = modelSelect.value;\n\n            if (!modelId) {\n                showError(t('please_select_model'));\n                return;\n            }\n\n            // Show result area and set \"Loading\" text\n            resultDiv.classList.remove('hidden');\n            resultPre.textContent = t('testing');\n\n            // Send test request directly to handle both success and error responses\n            let result = null;\n            try {\n                // Use direct fetch instead of apiFetch to get raw response\n                const response = await fetch('/api/admin/test-gemini-key', {\n                    method: 'POST',\n                    headers: {\n                        'Content-Type': 'application/json',\n                    },\n                    credentials: 'include',\n                    body: JSON.stringify({ keyId, modelId })\n                });\n\n                // Parse response regardless of status code\n                const contentType = response.headers.get(\"content-type\");\n                if (contentType && contentType.indexOf(\"application/json\") !== -1) {\n                    result = await response.json();\n                } else {\n                    const textContent = await response.text();\n                    result = {\n                        success: false,\n                        status: response.status,\n                        content: textContent || 'No response content'\n                    };\n                }\n\n                if (result) {\n                    const formattedContent = typeof result.content === 'object'\n                        ? JSON.stringify(result.content, null, 2)\n                        : result.content;\n\n                    if (result.success) {\n                        resultPre.textContent = `${t('test_passed')}\\n${t('status')}: ${result.status}\\n\\n${t('response')}:\\n${formattedContent}`;\n                        resultPre.className = 'text-xs bg-green-50 text-green-800 p-2 rounded overflow-x-auto';\n                    } else {\n                        resultPre.textContent = `${t('test_failed')}\\n${t('status')}: ${result.status}\\n\\n${t('response')}:\\n${formattedContent}`;\n                        resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';\n                    }\n                } else {\n                    resultPre.textContent = t('test_failed_no_response');\n                    resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';\n                }\n            } catch (error) {\n                // 只在测试区域显示网络错误\n                resultPre.textContent = t('test_failed_network', error.message || t('unknown_error'));\n                resultPre.className = 'text-xs bg-red-50 text-red-800 p-2 rounded overflow-x-auto';\n            }\n            return;\n        }\n\n        if (e.target.classList.contains('delete-gemini-key')) {\n            const keyId = e.target.dataset.id;\n            if (confirm(t('delete_confirm_gemini', keyId))) {\n                const modal = e.target.closest('.fixed.inset-0');\n                if (modal) {\n                    modal.classList.add('hidden');\n                }\n\n                const result = await apiFetch(`/gemini-keys/${encodeURIComponent(keyId)}`, {\n                    method: 'DELETE',\n                });\n                if (result && result.success) {\n                    await loadGeminiKeys(); // Wait for the list to reload\n                    showSuccess(`Gemini key ${keyId} deleted successfully!`);\n                }\n            }\n        }\n\n        // --- New: Clear Gemini Key Error ---\n        if (e.target.classList.contains('clear-gemini-key-error')) {\n            const keyId = e.target.dataset.id;\n            const button = e.target;\n            const modalErrorContainer = document.getElementById(`gemini-key-error-container-${keyId}`);\n            const modalErrorSpan = modalErrorContainer?.querySelector('span');\n\n            if (confirm(`Are you sure you want to clear the error status for key: ${keyId}?`)) {\n                const result = await apiFetch('/clear-key-error', {\n                    method: 'POST',\n                    body: JSON.stringify({ keyId }),\n                });\n\n                if (result && result.success) {\n                    // Get the corresponding card and data\n                    const cardItem = document.querySelector(`.card-item[data-key-id=\"${keyId}\"]`);\n                    \n                    // Find the current key's data to get the usage value\n                    const keyData = result.updatedKey || { usage: 0 }; // Use the updated key data from the API response if available, otherwise default to 0\n                    \n                    // Replace the warning icon container with the Total display\n                    const warningContainer = cardItem?.querySelector('.warning-icon-container');\n                    if (warningContainer) {\n                        const totalHTML = `\n                            <div class=\"text-xs px-2 py-1 bg-blue-100 text-blue-800 rounded-full whitespace-nowrap\">\n                                ${keyData.usage || '0'}\n                            </div>\n                        `;\n                        warningContainer.outerHTML = totalHTML;\n                    }\n                    \n                    // Remove error status text from modal\n                    const errorStatusP = button.closest('.modal-content').querySelector('p.text-red-600');\n                    if (errorStatusP) {\n                        errorStatusP.remove();\n                    }\n                    \n                    // Remove the button itself\n                    button.remove();\n                    showSuccess(`Error status cleared for key ${keyId}.`);\n                } else {\n                    // Show error within the modal\n                    if (modalErrorContainer && modalErrorSpan) {\n                        modalErrorSpan.textContent = result?.error || 'Failed to clear error status.';\n                        modalErrorContainer.classList.remove('hidden');\n                         setTimeout(() => modalErrorContainer.classList.add('hidden'), 5000);\n                    } else {\n                        showError(result?.error || 'Failed to clear error status.'); // Fallback to global error\n                    }\n                }\n            }\n        }\n        // --- End Clear Gemini Key Error ---\n    });\n\n     // Add Worker Key with validation\n    addWorkerKeyForm.addEventListener('submit', async (e) => {\n        e.preventDefault();\n        const formData = new FormData(addWorkerKeyForm);\n        const data = Object.fromEntries(formData.entries());\n        \n        // Validate worker key format - allow alphanumeric, hyphens, and underscores\n        const workerKeyValue = data.key?.trim();\n        if (!workerKeyValue) {\n            showError('Worker key is required.');\n            return;\n        }\n        \n        const validKeyRegex = /^[a-zA-Z0-9_\\-]+$/;\n        if (!validKeyRegex.test(workerKeyValue)) {\n            showError('Worker key can only contain letters, numbers, underscores (_), and hyphens (-).');\n            return;\n        }\n        \n        const result = await apiFetch('/worker-keys', {\n            method: 'POST',\n            body: JSON.stringify(data),\n        });\n        if (result && result.success) {\n            addWorkerKeyForm.reset();\n            await loadWorkerKeys(); // Wait for the list to reload\n            showSuccess('Worker key added successfully!');\n        }\n    });\n\n     // Delete Worker Key (no changes needed)\n    workerKeysListDiv.addEventListener('click', async (e) => {\n        if (e.target.classList.contains('delete-worker-key')) {\n            const key = e.target.dataset.key;\n\n             // Use key in the path for deletion, matching backend expectation\n            if (confirm(t('delete_confirm_worker', key))) {\n                const result = await apiFetch(`/worker-keys/${encodeURIComponent(key)}`, {\n                    method: 'DELETE',\n                });\n                if (result && result.success) {\n                    await loadWorkerKeys(); // Wait for the list to reload\n                    showSuccess(`Worker key ${key} deleted successfully!`);\n                }\n            }\n        }\n    });\n\n    // Save safety settings (no changes needed)\n    async function saveSafetySettingsToServer(key, isEnabled) {\n        try {\n            const result = await apiFetch('/worker-keys/safety-settings', {\n                method: 'POST',\n                body: JSON.stringify({\n                    key: key,\n                    safetyEnabled: isEnabled\n                }),\n            });\n            if (!result || !result.success) {\n                console.error('Failed to save safety settings to server');\n                showError('Failed to sync safety settings with server. Changes may not persist across browsers.');\n            }\n        } catch (error) {\n            console.error('Error saving safety settings to server:', error);\n            showError('Failed to sync safety settings with server. Changes may not persist across browsers.');\n        }\n    }\n\n    // Generate Random Worker Key with valid format\n    generateWorkerKeyBtn.addEventListener('click', () => {\n        const validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';\n        let randomKey = 'sk-';\n        \n        for (let i = 0; i < 20; i++) {\n            const randomIndex = Math.floor(Math.random() * validChars.length);\n            randomKey += validChars[randomIndex];\n        }\n        \n        workerKeyValueInput.value = randomKey;\n    });\n\n    // --- Model Form Logic ---\n    // Show/hide Custom Quota input based on category selection\n    modelCategorySelect.addEventListener('change', (e) => {\n        if (e.target.value === 'Custom') {\n            customQuotaDiv.classList.remove('hidden');\n            modelQuotaInput.required = true;\n        } else {\n            customQuotaDiv.classList.add('hidden');\n            modelQuotaInput.required = false;\n            modelQuotaInput.value = '';\n        }\n    });\n\n    // Add/Update Model - Modified Submit Handler\n    addModelForm.addEventListener('submit', async (e) => {\n        e.preventDefault();\n\n        // Check if there are any Gemini API keys before allowing model addition\n        const geminiKeysList = document.querySelectorAll('#gemini-keys-list .card-item');\n        if (geminiKeysList.length === 0) {\n            showError('无法添加模型：请先添加至少一个 Gemini API 密钥。');\n            return;\n        }\n\n        const formData = new FormData(addModelForm);\n        const data = {\n            id: formData.get('id').trim(),\n            category: formData.get('category')\n        };\n\n        // Only include dailyQuota if category is 'Custom' and input is visible/filled\n        if (data.category === 'Custom') {\n            const quotaInput = formData.get('dailyQuota')?.trim().toLowerCase();\n            if (quotaInput === undefined || quotaInput === null || quotaInput === '') {\n                 showError(\"Daily Quota is required for Custom models. Enter a positive number, 'none', or '0'.\");\n                 return; // Stop submission\n            }\n\n            if (quotaInput === 'none' || quotaInput === '0') {\n            } else {\n                const quotaValue = parseInt(quotaInput, 10);\n                if (isNaN(quotaValue) || quotaValue <= 0 || quotaInput !== quotaValue.toString()) {\n                     showError(\"Daily Quota for Custom models must be a positive whole number, 'none', or '0'.\");\n                     return;\n                }\n                data.dailyQuota = quotaValue;\n            }\n        }\n\n        const result = await apiFetch('/models', {\n            method: 'POST',\n            body: JSON.stringify(data),\n        });\n\n        if (result && result.success) {\n            addModelForm.reset();\n            customQuotaDiv.classList.add('hidden');\n            modelQuotaInput.required = false;\n            await loadModels(); // Wait for models to reload\n            await loadGeminiKeys(); // Wait for gemini keys to reload (as model changes affect them)\n            showSuccess(`Model ${data.id} added/updated successfully!`);\n        }\n    });\n\n     // Delete Model (no changes needed)\n    modelsListDiv.addEventListener('click', async (e) => {\n        if (e.target.classList.contains('delete-model')) {\n            const modelId = e.target.dataset.id;\n\n             // Use model ID in the path for deletion, matching backend expectation\n            if (confirm(t('delete_confirm_model', modelId))) {\n                const result = await apiFetch(`/models/${encodeURIComponent(modelId)}`, {\n                    method: 'DELETE',\n                });\n                if (result && result.success) {\n                    await loadModels(); // Wait for models to reload\n                    await loadGeminiKeys(); // Wait for gemini keys to reload\n                    showSuccess(`Model ${modelId} deleted successfully!`);\n                }\n            }\n        }\n    });\n\n    // --- Category Quotas Modal Logic ---\n    setCategoryQuotasBtn.addEventListener('click', async () => {\n        hideError(categoryQuotasErrorDiv);\n        const currentQuotas = await loadCategoryQuotas();\n        if (currentQuotas) {\n            proQuotaInput.value = currentQuotas.proQuota ?? 50;\n            flashQuotaInput.value = currentQuotas.flashQuota ?? 1500;\n            \n            // Set placeholders to show default values\n            proQuotaInput.placeholder = \"Default: 50\";\n            flashQuotaInput.placeholder = \"Default: 1500\";\n            \n            categoryQuotasModal.classList.remove('hidden');\n        } else {\n            showError(\"Could not load current category quotas.\", categoryQuotasErrorDiv, categoryQuotasErrorDiv);\n        }\n    });\n\n    closeCategoryQuotasModalBtn.addEventListener('click', () => {\n        categoryQuotasModal.classList.add('hidden');\n    });\n\n    cancelCategoryQuotasBtn.addEventListener('click', () => {\n        categoryQuotasModal.classList.add('hidden');\n    });\n\n    categoryQuotasModal.addEventListener('click', (e) => {\n        if (e.target === categoryQuotasModal) {\n            categoryQuotasModal.classList.add('hidden');\n        }\n    });\n\n    categoryQuotasForm.addEventListener('submit', async (e) => {\n        e.preventDefault();\n        hideError(categoryQuotasErrorDiv);\n\n        const proQuota = parseInt(proQuotaInput.value, 10);\n        const flashQuota = parseInt(flashQuotaInput.value, 10);\n\n        if (isNaN(proQuota) || proQuota < 0 || isNaN(flashQuota) || flashQuota < 0) {\n            showError(\"Quotas must be non-negative numbers.\", categoryQuotasErrorDiv, categoryQuotasErrorDiv);\n            return;\n        }\n\n        const result = await apiFetch('/category-quotas', {\n            method: 'POST',\n            body: JSON.stringify({ proQuota, flashQuota }),\n        });\n\n        if (result && result.success) {\n            cachedCategoryQuotas = { proQuota, flashQuota };\n            categoryQuotasModal.classList.add('hidden');\n            await loadGeminiKeys(); // Wait for gemini keys to reload\n            showSuccess('Category quotas saved successfully!');\n        } else {\n            // Error already shown by apiFetch\n             showError(result?.error || \"Failed to save category quotas.\", categoryQuotasErrorDiv, categoryQuotasErrorDiv);\n        }\n    });\n\n    // --- Individual Quota Modal Logic ---\n    closeIndividualQuotaModalBtn.addEventListener('click', () => {\n        individualQuotaModal.classList.add('hidden');\n    });\n\n    cancelIndividualQuotaBtn.addEventListener('click', () => {\n        individualQuotaModal.classList.add('hidden');\n    });\n\n    // --- Run All Test Logic ---\n    runAllTestBtn.addEventListener('click', async () => {\n        if (isRunningAllTests) {\n            return; // Prevent multiple concurrent tests\n        }\n\n        await runAllGeminiKeysTest();\n    });\n\n    cancelAllTestBtn.addEventListener('click', () => {\n        testCancelRequested = true;\n        testStatusText.textContent = t('cancelling_tests');\n        cancelAllTestBtn.disabled = true;\n    });\n\n    // Ignore All Errors Logic\n    ignoreAllErrorsBtn.addEventListener('click', async () => {\n        // Prevent concurrent operations\n        if (operationInProgress) {\n            showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');\n            return;\n        }\n\n        if (!confirm(t('ignore_all_errors_confirm'))) {\n            return;\n        }\n\n        try {\n            operationInProgress = true; // Lock operations\n            showLoading();\n            const result = await apiFetch('/clear-all-errors', {\n                method: 'POST',\n            });\n\n            if (result && result.success) {\n                if (result.clearedCount === 0) {\n                    showSuccess(t('no_error_keys_found'));\n                } else {\n                    showSuccess(t('error_keys_ignored', result.clearedCount));\n                }\n                await loadGeminiKeys(); // Reload the keys list\n            }\n        } catch (error) {\n            console.error('Error ignoring error keys:', error);\n            showError(t('failed_to_ignore_error_keys', error.message));\n        } finally {\n            operationInProgress = false; // Release lock\n            hideLoading();\n        }\n    });\n\n    // Clean Error Keys Logic\n    cleanErrorKeysBtn.addEventListener('click', async () => {\n        // Prevent concurrent operations\n        if (operationInProgress) {\n            showError(t('operation_in_progress') || 'Another operation is in progress. Please wait.');\n            return;\n        }\n\n        if (!confirm(t('clean_error_keys_confirm'))) {\n            return;\n        }\n\n        try {\n            operationInProgress = true; // Lock operations\n            showLoading();\n            const result = await apiFetch('/error-keys', {\n                method: 'DELETE',\n            });\n\n            if (result && result.success) {\n                if (result.deletedCount === 0) {\n                    showSuccess(t('no_error_keys_found'));\n                } else {\n                    showSuccess(t('error_keys_cleaned', result.deletedCount));\n                }\n                await loadGeminiKeys(); // Reload the keys list\n            }\n        } catch (error) {\n            console.error('Error cleaning error keys:', error);\n            showError(t('failed_to_clean_error_keys', error.message));\n        } finally {\n            operationInProgress = false; // Release lock\n            hideLoading();\n        }\n    });\n\n    individualQuotaModal.addEventListener('click', (e) => {\n        if (e.target === individualQuotaModal) {\n            individualQuotaModal.classList.add('hidden');\n        }\n    });\n\n    individualQuotaForm.addEventListener('submit', async (e) => {\n        e.preventDefault();\n        hideError(individualQuotaErrorDiv);\n\n        const modelId = individualQuotaModelIdInput.value;\n        const individualQuota = parseInt(individualQuotaValueInput.value, 10);\n\n        if (isNaN(individualQuota) || individualQuota < 0) {\n            showError(\"Individual quota must be a non-negative number.\", individualQuotaErrorDiv, individualQuotaErrorDiv);\n            return;\n        }\n\n        // Find the existing model to update\n        const modelToUpdate = cachedModels.find(m => m.id === modelId);\n        if (!modelToUpdate) {\n            showError(`Model ${modelId} not found.`, individualQuotaErrorDiv, individualQuotaErrorDiv);\n            return;\n        }\n\n        // Create the payload with existing data plus the new individualQuota\n        const payload = {\n            id: modelId,\n            category: modelToUpdate.category,\n            individualQuota: individualQuota > 0 ? individualQuota : undefined // If 0, set to undefined to remove quota\n        };\n\n        // If it's a Custom model, preserve the dailyQuota\n        if (modelToUpdate.category === 'Custom' && modelToUpdate.dailyQuota) {\n            payload.dailyQuota = modelToUpdate.dailyQuota;\n        }\n\n        const result = await apiFetch('/models', {\n            method: 'POST',\n            body: JSON.stringify(payload),\n        });\n\n        if (result && result.success) {\n            individualQuotaModal.classList.add('hidden');\n            await loadModels(); // Reload models to show updated quota\n            await loadGeminiKeys(); // Reload keys as they display model usage\n            \n            if (individualQuota > 0) {\n                showSuccess(`Individual quota for ${modelId} set to ${individualQuota}.`);\n            } else {\n                showSuccess(`Individual quota for ${modelId} removed.`);\n            }\n        } else {\n            showError(result?.error || \"Failed to set individual quota.\", individualQuotaErrorDiv, individualQuotaErrorDiv);\n        }\n    });\n\n\n    // Verify if the user is authorized; redirect directly if not\n    async function checkAuth() {\n        try {\n            if (localStorage.getItem('isLoggedIn') !== 'true') {\n                window.location.href = '/login';\n                return false;\n            }\n\n            const response = await fetch('/api/admin/models', { // Use an existing simple GET endpoint\n                method: 'GET',\n                credentials: 'include'\n            });\n            \n            // Check for redirects that might indicate auth issues\n            if (response.redirected) {\n                const redirectUrl = new URL(response.url);\n                if (redirectUrl.pathname.includes('login') || \n                    !redirectUrl.pathname.includes('/api/admin')) {\n                    console.log('Detected redirect to login page. Session likely expired.');\n                    localStorage.removeItem('isLoggedIn');\n                    window.location.href = '/login';\n                    return false;\n                }\n            }\n\n            if (!response.ok) {\n                if (response.status === 401 || response.status === 403 || \n                    (response.status >= 300 && response.status < 400)) {\n                    console.log(`User is not authorized. Auth check failed with status: ${response.status}. Redirecting to login page.`);\n                    localStorage.removeItem('isLoggedIn');\n                    window.location.href = '/login';\n                }\n                return false;\n            }\n            \n            localStorage.setItem('isLoggedIn', 'true');\n            authCheckingUI.classList.add('hidden');\n            unauthorizedUI.classList.add('hidden');\n            mainContentUI.classList.remove('hidden');\n            return true;\n\n        } catch (error) {\n            console.error('Authorization check failed:', error);\n            localStorage.removeItem('isLoggedIn');\n            window.location.href = '/login';\n            return false;\n        }\n    }\n\n    // --- Initial Load ---\n    async function initialLoad() {\n        const isAuthorized = await checkAuth();\n        if (!isAuthorized) {\n            console.log('User is not authorized. Aborting initial load.');\n            return;\n        }\n\n        try {\n            const results = await Promise.allSettled([\n                loadModels(),\n                loadCategoryQuotas(),\n                loadWorkerKeys()\n            ]);\n\n            // Check results for critical failures (models/quotas)\n            if (results[0].status === 'rejected') {\n                 console.error(`Initial load failed for models:`, results[0].reason);\n                 showError('Failed to load essential model data. Please refresh.');\n                 return;\n            }\n             if (results[1].status === 'rejected') {\n                 console.error(`Initial load failed for category quotas:`, results[1].reason);\n                 showError('Failed to load category quotas. Display might be incorrect.');\n            }\n             if (results[2].status === 'rejected') {\n                 console.error(`Initial load failed for worker keys:`, results[2].reason);\n            }\n\n            await loadGeminiKeys();\n            // After loading Gemini keys, try to load available Gemini models\n            await loadGeminiAvailableModels();\n\n            // Check for updates\n            await checkForUpdates();\n\n        } catch (error) {\n            console.error('Failed to load data:', error);\n            showError('Failed to load data. Please refresh the page or try again later.');\n        }\n\n        // Add logout button functionality\n        if (logoutButton) {\n            logoutButton.addEventListener('click', async () => {\n                showLoading();\n                try {\n                    localStorage.removeItem('isLoggedIn'); // Clear local login status\n                    const response = await fetch('/api/logout', { method: 'POST', credentials: 'include' });\n                    window.location.href = '/login';\n                } catch (error) {\n                    showError('Error during logout.');\n                } finally {\n                    hideLoading();\n                }\n            });\n        }\n    }\n\n    function initDarkMode() {\n        const savedTheme = localStorage.getItem('theme');\n        if (savedTheme === 'dark') {\n            document.body.setAttribute('data-theme', 'dark');\n            sunIcon.classList.add('hidden');\n            moonIcon.classList.remove('hidden');\n        } else {\n            document.body.setAttribute('data-theme', 'light');\n            sunIcon.classList.remove('hidden');\n            moonIcon.classList.add('hidden');\n        }\n\n        darkModeToggle.addEventListener('click', () => {\n            const currentTheme = document.body.getAttribute('data-theme');\n\n            if (currentTheme === 'light') {\n                document.body.setAttribute('data-theme', 'dark');\n                localStorage.setItem('theme', 'dark');\n                sunIcon.classList.add('hidden');\n                moonIcon.classList.remove('hidden');\n            } else {\n                document.body.setAttribute('data-theme', 'light');\n                localStorage.setItem('theme', 'light');\n                moonIcon.classList.add('hidden');\n                sunIcon.classList.remove('hidden');\n            }\n        });\n    }\n\n    function setupAuthRefresh() {\n        const authCheckInterval = 5 * 60 * 1000;\n        \n        setInterval(async () => {\n            console.log(\"Performing scheduled auth check...\");\n            try {\n                const response = await fetch('/api/admin/models', {\n                    method: 'GET',\n                    credentials: 'include'\n                });\n                \n                if (response.redirected) {\n                    const redirectUrl = new URL(response.url);\n                    if (redirectUrl.pathname.includes('login') || \n                        !redirectUrl.pathname.includes('/api/admin')) {\n                        console.log('Session expired during scheduled check. Redirecting to login.');\n                        localStorage.removeItem('isLoggedIn');\n                        window.location.href = '/login';\n                    }\n                }\n                \n                if (!response.ok) {\n                    if (response.status === 401 || response.status === 403 || \n                        (response.status >= 300 && response.status < 400)) {\n                        console.log(`Auth check failed with status: ${response.status}. Redirecting to login.`);\n                        localStorage.removeItem('isLoggedIn');\n                        window.location.href = '/login';\n                    }\n                }\n            } catch (error) {\n                console.error('Scheduled auth check failed:', error);\n            }\n        }, authCheckInterval);\n    }\n\n    // --- Tab Switching Functions ---\n    function switchTab(tabName) {\n        // Update tab buttons\n        document.querySelectorAll('.api-tab').forEach(tab => {\n            tab.classList.remove('active');\n        });\n        document.getElementById(`${tabName}-tab`).classList.add('active');\n\n        // Update tab content\n        document.querySelectorAll('.tab-content').forEach(content => {\n            content.classList.add('hidden');\n        });\n        document.getElementById(`${tabName}-content`).classList.remove('hidden');\n\n        // Handle Managed Models container visibility\n        const modelsListElement = document.getElementById('models-list');\n        const managedModelsSection = modelsListElement ? modelsListElement.closest('section') : null;\n        if (managedModelsSection) {\n            if (tabName === 'vertex') {\n                // Hide Managed Models container when Vertex tab is active\n                managedModelsSection.classList.add('hidden');\n            } else {\n                // Show Managed Models container when Gemini tab is active\n                managedModelsSection.classList.remove('hidden');\n            }\n        }\n    }\n\n    // --- Vertex Configuration Functions ---\n    async function loadVertexConfig() {\n        try {\n            const config = await apiFetch('/vertex-config');\n            if (config) {\n                renderVertexConfig(config);\n            } else {\n                renderVertexConfig(null);\n            }\n        } catch (error) {\n            console.error('Error loading Vertex config:', error);\n            renderVertexConfig(null);\n        }\n    }\n\n    function renderVertexConfig(config) {\n        if (!config || (!config.expressApiKey && !config.vertexJson)) {\n            vertexConfigInfo.innerHTML = '<p class=\"text-gray-500\" data-i18n=\"no_vertex_config\"></p>';\n            vertexStatus.innerHTML = '<span class=\"px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full\" data-i18n=\"disabled\"></span>';\n\n            // Clear form\n            document.getElementById('express-api-key').value = '';\n            document.getElementById('vertex-json').value = '';\n            document.querySelector('input[name=\"auth_mode\"][value=\"service_account\"]').checked = true;\n            toggleAuthMode();\n\n            // Apply translations\n            if (window.i18n) {\n                window.i18n.applyTranslations();\n            }\n            return;\n        }\n\n        // Update status\n        vertexStatus.innerHTML = '<span class=\"px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full\" data-i18n=\"enabled\"></span>';\n\n        // Update info display\n        if (config.expressApiKey) {\n            vertexConfigInfo.innerHTML = `\n                <p><strong data-i18n=\"auth_mode\"></strong>: <span data-i18n=\"express_mode\"></span></p>\n                <p><strong data-i18n=\"api_key\"></strong>: ${config.expressApiKey.substring(0, 10)}...${config.expressApiKey.substring(config.expressApiKey.length - 4)}</p>\n            `;\n\n            // Don't populate form fields when displaying existing config\n            // This ensures the form is clean for new input\n            document.querySelector('input[name=\"auth_mode\"][value=\"express\"]').checked = true;\n            document.getElementById('express-api-key').value = '';\n            document.getElementById('vertex-json').value = '';\n        } else if (config.vertexJson) {\n            try {\n                const jsonData = JSON.parse(config.vertexJson);\n                vertexConfigInfo.innerHTML = `\n                    <p><strong data-i18n=\"auth_mode\"></strong>: <span data-i18n=\"service_account_mode\"></span></p>\n                    <p><strong data-i18n=\"project_id\"></strong>: ${jsonData.project_id || 'N/A'}</p>\n                    <p><strong data-i18n=\"client_email\"></strong>: ${jsonData.client_email || 'N/A'}</p>\n                `;\n\n                // Don't populate form fields when displaying existing config\n                document.querySelector('input[name=\"auth_mode\"][value=\"service_account\"]').checked = true;\n                document.getElementById('express-api-key').value = '';\n                document.getElementById('vertex-json').value = '';\n            } catch (e) {\n                vertexConfigInfo.innerHTML = '<p class=\"text-red-500\" data-i18n=\"invalid_json\"></p>';\n            }\n        }\n\n        toggleAuthMode();\n\n        // Apply translations\n        if (window.i18n) {\n            window.i18n.applyTranslations();\n        }\n    }\n\n    function toggleAuthMode() {\n        const authMode = document.querySelector('input[name=\"auth_mode\"]:checked').value;\n        const expressSection = document.getElementById('express-api-key-section');\n        const serviceAccountSection = document.getElementById('service-account-section');\n\n        if (authMode === 'service_account') {\n            serviceAccountSection.classList.remove('hidden');\n            expressSection.classList.add('hidden');\n        } else {\n            expressSection.classList.remove('hidden');\n            serviceAccountSection.classList.add('hidden');\n        }\n    }\n\n    async function saveVertexConfig(configData) {\n        try {\n            const result = await apiFetch('/vertex-config', {\n                method: 'POST',\n                body: JSON.stringify(configData),\n            });\n\n            if (result && result.success) {\n                showSuccess('Vertex 配置保存成功！');\n\n                // Clear the form after successful save\n                document.getElementById('express-api-key').value = '';\n                document.getElementById('vertex-json').value = '';\n\n                await loadVertexConfig(); // Reload to show updated config\n                return true;\n            } else {\n                showError('保存 Vertex 配置失败');\n                return false;\n            }\n        } catch (error) {\n            console.error('Error saving Vertex config:', error);\n            showError('保存 Vertex 配置时发生错误');\n            return false;\n        }\n    }\n\n    async function testVertexConfig() {\n        try {\n            showLoading();\n            const result = await apiFetch('/vertex-config/test', {\n                method: 'POST',\n            });\n\n            hideLoading();\n\n            if (result && result.success) {\n                showSuccess('Vertex 配置测试成功！');\n            } else {\n                showError(result?.error || '测试 Vertex 配置失败');\n            }\n        } catch (error) {\n            hideLoading();\n            console.error('Error testing Vertex config:', error);\n            showError('测试 Vertex 配置时发生错误');\n        }\n    }\n\n    async function clearVertexConfig() {\n        if (!confirm('确定要清除 Vertex 配置吗？')) {\n            return;\n        }\n\n        try {\n            const result = await apiFetch('/vertex-config', {\n                method: 'DELETE',\n            });\n\n            if (result && result.success) {\n                showSuccess('Vertex 配置已清除');\n                await loadVertexConfig(); // Reload to show cleared config\n            } else {\n                showError('清除 Vertex 配置失败');\n            }\n        } catch (error) {\n            console.error('Error clearing Vertex config:', error);\n            showError('清除 Vertex 配置时发生错误');\n        }\n    }\n\n    // --- Event Listeners ---\n\n    // Tab switching\n    geminiTab.addEventListener('click', () => switchTab('gemini'));\n    vertexTab.addEventListener('click', () => switchTab('vertex'));\n\n    // Authentication mode toggle\n    document.querySelectorAll('input[name=\"auth_mode\"]').forEach(radio => {\n        radio.addEventListener('change', toggleAuthMode);\n    });\n\n    // Vertex configuration form\n    vertexConfigForm.addEventListener('submit', async (e) => {\n        e.preventDefault();\n        const formData = new FormData(vertexConfigForm);\n        const authMode = formData.get('auth_mode');\n\n        let configData = {};\n\n        if (authMode === 'express') {\n            const expressApiKey = formData.get('express_api_key');\n            if (!expressApiKey || !expressApiKey.trim()) {\n                showError('请输入 Express API Key');\n                return;\n            }\n            configData.expressApiKey = expressApiKey.trim();\n        } else {\n            const vertexJson = formData.get('vertex_json');\n            if (!vertexJson || !vertexJson.trim()) {\n                showError('请输入 Service Account JSON');\n                return;\n            }\n\n            // Validate JSON\n            try {\n                JSON.parse(vertexJson);\n                configData.vertexJson = vertexJson.trim();\n            } catch (e) {\n                showError('无效的 JSON 格式');\n                return;\n            }\n        }\n\n        // Check if configuration already exists\n        try {\n            const existingConfig = await apiFetch('/vertex-config');\n            if (existingConfig && (existingConfig.expressApiKey || existingConfig.vertexJson)) {\n                // Configuration exists, ask for confirmation\n                const confirmMessage = window.i18n ?\n                    window.i18n.translate('vertex_config_overwrite_confirm') :\n                    '检测到已存在 Vertex 配置，是否要覆盖当前配置？';\n\n                if (!confirm(confirmMessage)) {\n                    return; // User cancelled\n                }\n            }\n        } catch (error) {\n            console.warn('Failed to check existing Vertex config:', error);\n            // Continue with save even if check fails\n        }\n\n        await saveVertexConfig(configData);\n    });\n\n    // Test and clear buttons\n    testVertexConfigBtn.addEventListener('click', testVertexConfig);\n    clearVertexConfigBtn.addEventListener('click', clearVertexConfig);\n\n    // Settings modal functionality\n    setupSettingsModal();\n\n    initialLoad();\n    initDarkMode();\n    setupAuthRefresh();\n\n    // Load Vertex config after initial load\n    loadVertexConfig();\n\n    // --- Settings Modal Functions ---\n    function setupSettingsModal() {\n        const settingsButton = document.getElementById('settings-button');\n        const settingsModal = document.getElementById('settings-modal');\n        const closeModalButton = document.getElementById('close-settings-modal');\n        const cancelButton = document.getElementById('cancel-settings');\n        const settingsForm = document.getElementById('settings-form');\n        const keepaliveToggle = document.getElementById('keepalive-toggle');\n        const maxRetryInput = document.getElementById('max-retry-input');\n\n        // Open modal\n        settingsButton.addEventListener('click', () => {\n            loadSystemSettings();\n            settingsModal.classList.remove('hidden');\n        });\n\n        // Close modal\n        function closeModal() {\n            settingsModal.classList.add('hidden');\n        }\n\n        closeModalButton.addEventListener('click', closeModal);\n        cancelButton.addEventListener('click', closeModal);\n\n        // Close modal when clicking outside\n        settingsModal.addEventListener('click', (e) => {\n            if (e.target === settingsModal) {\n                closeModal();\n            }\n        });\n\n        // Handle form submission\n        settingsForm.addEventListener('submit', async (e) => {\n            e.preventDefault();\n            await saveSystemSettings();\n        });\n    }\n\n    async function loadSystemSettings() {\n        try {\n            const settings = await apiFetch('/system-settings');\n            console.log('Loaded settings:', settings); // Debug log\n\n            // Set KEEPALIVE toggle\n            const keepaliveToggle = document.getElementById('keepalive-toggle');\n            keepaliveToggle.checked = settings.keepalive === '1' || settings.keepalive === 1 || settings.keepalive === true;\n\n            // Set MAX_RETRY input\n            const maxRetryInput = document.getElementById('max-retry-input');\n            maxRetryInput.value = settings.maxRetry || 3;\n\n            // Set Web Search toggle\n            const webSearchToggle = document.getElementById('web-search-toggle');\n            webSearchToggle.checked = settings.webSearch === '1' || settings.webSearch === 1 || settings.webSearch === true;\n\n            // Set Auto Test toggle\n            const autoTestToggle = document.getElementById('auto-test-toggle');\n            autoTestToggle.checked = settings.autoTest === '1' || settings.autoTest === 1 || settings.autoTest === true;\n\n        } catch (error) {\n            console.error('Error loading system settings:', error);\n            // Set default values\n            document.getElementById('keepalive-toggle').checked = false;\n            document.getElementById('max-retry-input').value = 3;\n            document.getElementById('web-search-toggle').checked = false;\n            document.getElementById('auto-test-toggle').checked = false;\n        }\n    }\n\n    async function saveSystemSettings() {\n        try {\n            const keepaliveToggle = document.getElementById('keepalive-toggle');\n            const maxRetryInput = document.getElementById('max-retry-input');\n            const webSearchToggle = document.getElementById('web-search-toggle');\n            const autoTestToggle = document.getElementById('auto-test-toggle');\n\n            const settings = {\n                keepalive: keepaliveToggle.checked ? '1' : '0',\n                maxRetry: parseInt(maxRetryInput.value) || 3,\n                webSearch: webSearchToggle.checked ? '1' : '0',\n                autoTest: autoTestToggle.checked ? '1' : '0'\n            };\n\n            const result = await apiFetch('/system-settings', {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json'\n                },\n                body: JSON.stringify(settings)\n            });\n\n            if (result.success) {\n                showSuccess('系统设置已保存');\n                document.getElementById('settings-modal').classList.add('hidden');\n            } else {\n                showError('保存系统设置失败');\n            }\n        } catch (error) {\n            console.error('Error saving system settings:', error);\n            showError('保存系统设置时发生错误');\n        }\n    }\n});\n\n    // --- Update Check Functions ---\n   /**\n    * Compares two semantic version strings.\n    * @param {string} v1 - The first version string.\n    * @param {string} v2 - The second version string.\n    * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if v1 === v2.\n    */\n   function compareVersions(v1, v2) {\n       const parts1 = v1.split('.').map(Number);\n       const parts2 = v2.split('.').map(Number);\n       const len = Math.max(parts1.length, parts2.length);\n\n       for (let i = 0; i < len; i++) {\n           const p1 = parts1[i] || 0;\n           const p2 = parts2[i] || 0;\n           if (p1 > p2) return 1;\n           if (p1 < p2) return -1;\n       }\n       return 0;\n   }\n\n   async function checkForUpdates() {\n       try {\n           // 1. Fetch local version\n           const localVersionResponse = await fetch('/admin/version.txt?t=' + new Date().getTime());\n           if (!localVersionResponse.ok) {\n               console.warn('Could not fetch local version.txt');\n               return;\n           }\n           const localVersion = (await localVersionResponse.text()).trim();\n\n           // 2. Fetch latest release from GitHub\n           const githubApiResponse = await fetch('https://api.github.com/repos/dreamhartley/gemini-proxy-panel/releases/latest');\n           if (!githubApiResponse.ok) {\n               console.warn('Could not fetch latest release from GitHub.');\n               // Show version display even if GitHub check fails\n               showVersionDisplay(localVersion);\n               return;\n           }\n           const latestRelease = await githubApiResponse.json();\n           const latestVersion = latestRelease.tag_name.replace('v', '').trim();\n\n           // 3. Compare versions and show appropriate notifier\n           const updateNotifier = document.getElementById('update-notifier');\n           if (!updateNotifier) return;\n\n           if (compareVersions(latestVersion, localVersion) > 0) {\n               // Show update available notification (red)\n               updateNotifier.classList.remove('hidden', 'version-display');\n               updateNotifier.textContent = 'New';\n               updateNotifier.setAttribute('data-tooltip', t('update_available'));\n               updateNotifier.removeAttribute('title');\n           } else {\n               // Show current version (blue)\n               showVersionDisplay(localVersion);\n           }\n       } catch (error) {\n           console.error('Error checking for updates:', error);\n           // Try to show version display even if update check fails\n           try {\n               const localVersionResponse = await fetch('/admin/version.txt?t=' + new Date().getTime());\n               if (localVersionResponse.ok) {\n                   const localVersion = (await localVersionResponse.text()).trim();\n                   showVersionDisplay(localVersion);\n               }\n           } catch (versionError) {\n               console.error('Error fetching local version:', versionError);\n           }\n       }\n   }\n\n   function showVersionDisplay(version) {\n       const updateNotifier = document.getElementById('update-notifier');\n       if (updateNotifier) {\n           updateNotifier.classList.remove('hidden');\n           updateNotifier.classList.add('version-display');\n           updateNotifier.textContent = `v${version}`;\n           updateNotifier.setAttribute('data-tooltip', t('current_is_latest'));\n           updateNotifier.removeAttribute('title');\n       }\n   }\n\n// --- Debugging Commands ---\nwindow.show = function(what) {\n    if (what === 'update') {\n        const updateNotifier = document.getElementById('update-notifier');\n        if (updateNotifier) {\n            updateNotifier.classList.remove('hidden', 'version-display');\n            updateNotifier.textContent = 'New';\n            updateNotifier.setAttribute('data-tooltip', t('update_available'));\n            updateNotifier.removeAttribute('title');\n            console.log(\"Debug: Forcibly showing update notifier.\");\n            return \"Update notifier shown.\";\n        } else {\n            const msg = \"Debug Error: #update-notifier element not found.\";\n            console.error(msg);\n            return msg;\n        }\n    } else if (what === 'version') {\n        const updateNotifier = document.getElementById('update-notifier');\n        if (updateNotifier) {\n            showVersionDisplay('1.2.0');\n            console.log(\"Debug: Forcibly showing version display.\");\n            return \"Version display shown.\";\n        } else {\n            const msg = \"Debug Error: #update-notifier element not found.\";\n            console.error(msg);\n            return msg;\n        }\n    }\n    return `Unknown command: ${what}`;\n};\n"
  },
  {
    "path": "public/admin/style.css",
    "content": "/* Optional: Add custom CSS rules here if needed */\n\n/* Light mode warm background colors */\nbody[data-theme=\"light\"] {\n    background-color: #f5f3f0 !important; /* Warm light khaki background */\n}\n\nbody[data-theme=\"light\"] .bg-gray-100 {\n    background-color: #f5f3f0 !important; /* Warm light khaki background */\n}\n\nbody[data-theme=\"light\"] .bg-white {\n    background-color: #faf9f7 !important; /* Slightly warmer white for cards */\n}\n\nbody[data-theme=\"light\"] section {\n    background-color: #faf9f7 !important; /* Slightly warmer white for sections */\n}\n\n/* Light mode border and shadow adjustments for warm theme */\nbody[data-theme=\"light\"] .border,\nbody[data-theme=\"light\"] .border-gray-200,\nbody[data-theme=\"light\"] .border-gray-300 {\n    border-color: #e6ddd4 !important; /* Warmer border color */\n}\n\nbody[data-theme=\"light\"] .shadow,\nbody[data-theme=\"light\"] .shadow-md,\nbody[data-theme=\"light\"] .shadow-lg {\n    box-shadow: 0 1px 3px 0 rgba(139, 116, 88, 0.1), 0 1px 2px 0 rgba(139, 116, 88, 0.06) !important; /* Warmer shadow */\n}\n\n/* Adjust modal and card backgrounds for warm theme */\nbody[data-theme=\"light\"] .modal-content,\nbody[data-theme=\"light\"] .card-item {\n    background-color: #faf9f7 !important;\n    border-color: #e6ddd4 !important;\n}\n\n/* Light mode input field adjustments for warm theme */\nbody[data-theme=\"light\"] input[type=\"text\"],\nbody[data-theme=\"light\"] input[type=\"password\"],\nbody[data-theme=\"light\"] input[type=\"number\"],\nbody[data-theme=\"light\"] textarea,\nbody[data-theme=\"light\"] select {\n    background-color: #f8f6f3 !important; /* Warm input background */\n    border-color: #e6ddd4 !important; /* Warmer border */\n}\n\nbody[data-theme=\"light\"] input[type=\"text\"]:focus,\nbody[data-theme=\"light\"] input[type=\"password\"]:focus,\nbody[data-theme=\"light\"] input[type=\"number\"]:focus,\nbody[data-theme=\"light\"] textarea:focus,\nbody[data-theme=\"light\"] select:focus {\n    background-color: #faf9f7 !important; /* Slightly lighter when focused */\n    border-color: #3b82f6 !important; /* Keep blue focus border */\n    box-shadow: 0 0 0 1px #3b82f6 !important;\n}\n\n/* Light mode button adjustments for warm theme */\nbody[data-theme=\"light\"] .bg-gray-100 {\n    background-color: #f0ede8 !important; /* Warmer gray-100 */\n}\n\nbody[data-theme=\"light\"] .bg-gray-200 {\n    background-color: #e8e3dc !important; /* Warmer gray-200 */\n}\n\nbody[data-theme=\"light\"] .hover\\:bg-gray-200:hover {\n    background-color: #e0d9d0 !important; /* Warmer hover state */\n}\n\nbody[data-theme=\"light\"] .hover\\:bg-gray-300:hover {\n    background-color: #d6cfc4 !important; /* Warmer hover state */\n}\n\n/* Light mode additional button and hover state adjustments */\nbody[data-theme=\"light\"] .bg-white {\n    background-color: #faf9f7 !important; /* Warmer white for buttons */\n}\n\nbody[data-theme=\"light\"] .hover\\:bg-gray-50:hover {\n    background-color: #f5f2ed !important; /* Warmer hover state */\n}\n\nbody[data-theme=\"light\"] .hover\\:bg-red-50:hover {\n    background-color: #fef7f7 !important; /* Slightly warmer red hover */\n}\n\n/* Statistics bar styles */\n.stats-bar {\n    transition: box-shadow 0.2s ease; /* Removed background-color transition */\n    border: 1px solid #dbeafe; /* Light blue border */\n    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); /* Subtle shadow */\n}\n\n.stats-bar:hover {\n    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.08); /* Slightly larger shadow on hover */\n}\n\n/* Key cards container transition - smoother animation */\n.keys-grid {\n    overflow: hidden;\n    transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease-in-out;\n    max-height: 1000px; /* Set a reasonable max-height for transition */\n}\n\n/* Add specific style for hidden state for smoother transition */\n.keys-grid.hidden {\n    max-height: 0;\n    opacity: 0;\n    margin-top: 0; /* Avoid margin when hidden */\n    /* Ensure padding doesn't interfere when height is 0 */\n    padding-top: 0;\n    padding-bottom: 0;\n    /* Add delay to opacity transition to wait for height change */\n    transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease-in-out 0.1s;\n}\n\n\n/* Toggle icon transitions with rotation */\n.toggle-icon svg {\n    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* Smoother cubic-bezier */\n}\n\n/* Rotate expand icon when grid is collapsed (default state when hidden) */\n.stats-bar .expand-icon {\n    transform: rotate(0deg);\n}\n.stats-bar .collapse-icon {\n    transform: rotate(180deg);\n}\n/* No need for specific rules when hidden/shown, script handles swapping icons */\n\n\n/* Dark mode adjustments for statistics bar */\nbody[data-theme=\"dark\"] .stats-bar {\n    /* Removed background-color for dark mode */\n    color: #d1d5db !important; /* Adjusted dark text */\n    border-color: #4b5563 !important; /* Adjusted dark border */\n    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); /* Slightly darker shadow */\n}\n\nbody[data-theme=\"dark\"] .stats-bar:hover {\n    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15); /* Slightly darker hover shadow */\n}\n\nbody[data-theme=\"dark\"] .toggle-icon svg {\n    color: #9ca3af !important; /* Adjusted dark icon color */\n}\n\n/* Style for the individual key cards (optional improvements) */\nbody[data-theme=\"dark\"] .card-item {\n    background-color: #374151; /* Dark background for cards */\n    border-color: #4b5563;\n    color: #d1d5db;\n}\nbody[data-theme=\"dark\"] .card-item h3 {\n    color: #f3f4f6; /* Lighter heading in dark mode */\n}\nbody[data-theme=\"dark\"] .card-item p {\n    color: #9ca3af; /* Lighter secondary text */\n}\nbody[data-theme=\"dark\"] .card-item .bg-blue-100 {\n    background-color: #2563eb; /* Darker blue badge bg */\n    color: #eff6ff; /* Light text for badge */\n}\n\n/* Tab styles */\n.api-tab {\n    border-bottom-color: transparent;\n    color: #6b7280;\n    transition: all 0.2s ease;\n}\n\n.api-tab:hover {\n    color: #374151;\n    border-bottom-color: #d1d5db;\n}\n\n.api-tab.active {\n    color: #3b82f6;\n    border-bottom-color: #3b82f6;\n}\n\n/* Tab content */\n.tab-content {\n    transition: opacity 0.2s ease;\n}\n\n.tab-content.hidden {\n    display: none;\n}\n\n/* Dark mode tab styles */\nbody[data-theme=\"dark\"] .api-tab {\n    color: #9ca3af;\n}\n\nbody[data-theme=\"dark\"] .api-tab:hover {\n    color: #d1d5db;\n    border-bottom-color: #4b5563;\n}\n\nbody[data-theme=\"dark\"] .api-tab.active {\n    color: #60a5fa;\n    border-bottom-color: #60a5fa;\n}\n\n/* Vertex configuration display styles */\n#vertex-config-display {\n    transition: background-color 0.2s ease;\n}\n\nbody[data-theme=\"dark\"] #vertex-config-display {\n    background-color: #374151;\n    border-color: #4b5563;\n    color: #d1d5db;\n}\n\n/* Authentication mode radio buttons */\n.form-radio {\n    color: #3b82f6;\n}\n\nbody[data-theme=\"dark\"] .form-radio {\n    background-color: #374151;\n    border-color: #4b5563;\n    color: #60a5fa;\n}\n\nbody[data-theme=\"dark\"] .form-radio:checked {\n    background-color: #3b82f6;\n    border-color: #3b82f6;\n}\n\n/* Status badges in dark mode */\n\n\n/* Button styles in dark mode */\nbody[data-theme=\"dark\"] .border-red-300 {\n    border-color: #dc2626 !important;\n}\n\nbody[data-theme=\"dark\"] .text-red-700 {\n    color: #fca5a5 !important;\n}\n\nbody[data-theme=\"dark\"] .hover\\:bg-red-50:hover {\n    background-color: #7f1d1d !important;\n}\n\n/* Form elements in dark mode */\nbody[data-theme=\"dark\"] input[type=\"text\"],\nbody[data-theme=\"dark\"] textarea,\nbody[data-theme=\"dark\"] select {\n    background-color: #374151;\n    border-color: #4b5563;\n    color: #d1d5db;\n}\n\nbody[data-theme=\"dark\"] input[type=\"text\"]:focus,\nbody[data-theme=\"dark\"] textarea:focus,\nbody[data-theme=\"dark\"] select:focus {\n    border-color: #60a5fa;\n    box-shadow: 0 0 0 1px #60a5fa;\n}\n\nbody[data-theme=\"dark\"] input[type=\"text\"]::placeholder,\nbody[data-theme=\"dark\"] textarea::placeholder {\n    color: #9ca3af;\n}\n\n/* Dark mode base styles */\nbody[data-theme=\"dark\"] {\n    background-color: #1a202c !important;\n    color: #e2e8f0 !important;\n}\n\nbody[data-theme=\"dark\"] .container,\nbody[data-theme=\"dark\"] #main-content {\n    background-color: #1a202c !important;\n}\n\nbody[data-theme=\"dark\"] .bg-white,\nbody[data-theme=\"dark\"] section,\nbody[data-theme=\"dark\"] .modal-content,\nbody[data-theme=\"dark\"] .card-item {\n    background-color: #2d3748 !important;\n}\n\nbody[data-theme=\"dark\"] .bg-gray-100 {\n    background-color: #1a202c !important;\n}\n\nbody[data-theme=\"dark\"] .text-gray-700,\nbody[data-theme=\"dark\"] .text-gray-800,\nbody[data-theme=\"dark\"] .text-gray-900,\nbody[data-theme=\"dark\"] .font-medium,\nbody[data-theme=\"dark\"] h1,\nbody[data-theme=\"dark\"] h2,\nbody[data-theme=\"dark\"] h3 {\n    color: #e2e8f0 !important;\n}\n\nbody[data-theme=\"dark\"] .text-gray-500,\nbody[data-theme=\"dark\"] .text-gray-600 {\n    color: #a0aec0 !important;\n}\n\nbody[data-theme=\"dark\"] .border,\nbody[data-theme=\"dark\"] input,\nbody[data-theme=\"dark\"] select,\nbody[data-theme=\"dark\"] textarea {\n    border-color: #4a5568 !important;\n}\n\nbody[data-theme=\"dark\"] input,\nbody[data-theme=\"dark\"] select,\nbody[data-theme=\"dark\"] textarea {\n    background-color: #2d3748 !important;\n    color: #e2e8f0 !important;\n}\n\nbody[data-theme=\"dark\"] .bg-gray-200,\nbody[data-theme=\"dark\"] .bg-gray-300 {\n    background-color: #4a5568 !important;\n}\n\nbody[data-theme=\"dark\"] .hover\\:bg-gray-300:hover {\n    background-color: #2d3748 !important;\n}\n\nbody[data-theme=\"dark\"] .bg-indigo-600 {\n    background-color: #4c51bf !important;\n}\n\nbody[data-theme=\"dark\"] .hover\\:bg-indigo-700:hover {\n    background-color: #434190 !important;\n}\n\nbody[data-theme=\"dark\"] .text-indigo-600 {\n    color: #7f9cf5 !important;\n}\n\nbody[data-theme=\"dark\"] .hover\\:text-indigo-800:hover {\n    color: #6b46c1 !important;\n}\n\nbody[data-theme=\"dark\"] .bg-blue-50,\nbody[data-theme=\"dark\"] .bg-blue-100 {\n    background-color: #2c5282 !important;\n}\n\nbody[data-theme=\"dark\"] .text-blue-800 {\n    color: #bee3f8 !important;\n}\n\nbody[data-theme=\"dark\"] .text-xs.px-2.py-1.bg-blue-100.text-blue-800.rounded-full {\n    background-color: #3182ce !important;\n    color: #ffffff !important;\n}\n\nbody[data-theme=\"dark\"] .p-4.border.rounded-md.bg-white {\n    background-color: #2d3748 !important;\n}\n\nbody[data-theme=\"dark\"] .text-gray-900 {\n    color: #e2e8f0 !important;\n}\n\nbody[data-theme=\"dark\"] .focus\\:border-indigo-500:focus,\nbody[data-theme=\"dark\"] .focus\\:ring-indigo-500:focus {\n    border-color: #7f9cf5 !important;\n    box-shadow: 0 0 0 1px #7f9cf5 !important;\n}\n\n/* Legacy toggle switches (for other parts of the app) */\nbody[data-theme=\"dark\"] .toggle-label {\n    background-color: #4a5568 !important;\n}\n\nbody[data-theme=\"dark\"] .toggle-checkbox:checked + .toggle-label {\n    background-color: #68D391 !important;\n}\n\nbody[data-theme=\"dark\"] .toggle-checkbox {\n    border-color: #4a5568 !important;\n}\n\nbody[data-theme=\"dark\"] .toggle-checkbox:checked {\n    border-color: #68D391 !important;\n}\n\nbody[data-theme=\"dark\"] label[for^=\"safety-toggle\"] {\n    color: #e2e8f0 !important;\n}\n\nbody[data-theme=\"dark\"] .text-green-600 {\n    color: #68D391 !important;\n}\n\nbody[data-theme=\"dark\"] .text-red-600 {\n    color: #F56565 !important;\n}\n\n/* Settings Modal Toggle Switch */\n.toggle-switch {\n    position: relative;\n    display: inline-block;\n    width: 56px;\n    height: 32px;\n    align-self: center;\n    flex-shrink: 0;\n}\n\n.toggle-switch input {\n    opacity: 0;\n    width: 0;\n    height: 0;\n}\n\n.toggle-slider {\n    position: absolute;\n    cursor: pointer;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-color: #ccc;\n    transition: all .3s ease;\n    border-radius: 32px;\n}\n\n.toggle-slider:before {\n    position: absolute;\n    content: \"\";\n    height: 24px;\n    width: 24px;\n    left: 4px;\n    top: 4px;\n    background-color: white;\n    transition: all .3s ease;\n    border-radius: 50%;\n    box-shadow: 0 2px 4px rgba(0,0,0,0.2);\n}\n\ninput:checked + .toggle-slider {\n    background-color: #10B981;\n}\n\ninput:checked + .toggle-slider:before {\n    transform: translateX(24px);\n}\n\n/* Dark mode for settings modal */\nbody[data-theme=\"dark\"] #settings-modal .bg-white {\n    background-color: #1f2937 !important;\n    color: #e5e7eb !important;\n}\n\nbody[data-theme=\"dark\"] #settings-modal .text-gray-900 {\n    color: #e5e7eb !important;\n}\n\nbody[data-theme=\"dark\"] #settings-modal .text-gray-700 {\n    color: #d1d5db !important;\n}\n\nbody[data-theme=\"dark\"] #settings-modal .text-gray-500 {\n    color: #9ca3af !important;\n}\n\nbody[data-theme=\"dark\"] #settings-modal .border-gray-300 {\n    border-color: #4b5563 !important;\n}\n\nbody[data-theme=\"dark\"] #settings-modal input[type=\"number\"] {\n    background-color: #374151 !important;\n    color: #e5e7eb !important;\n    border-color: #4b5563 !important;\n}\n\nbody[data-theme=\"dark\"] #settings-modal .bg-gray-100 {\n    background-color: #374151 !important;\n    color: #e5e7eb !important;\n}\n\nbody[data-theme=\"dark\"] #settings-modal .bg-gray-100:hover {\n    background-color: #4b5563 !important;\n}\n\n/* Dark mode for toggle switch */\nbody[data-theme=\"dark\"] .toggle-slider {\n    background-color: #4b5563 !important;\n}\n\nbody[data-theme=\"dark\"] input:checked + .toggle-slider {\n    background-color: #10B981 !important;\n}\n\n/* Custom dropdown dark mode styles */\nbody[data-theme=\"dark\"] #custom-model-dropdown {\n    background-color: #2d3748 !important;\n    border-color: #4a5568 !important;\n}\n\nbody[data-theme=\"dark\"] #custom-model-dropdown div {\n    color: #e2e8f0 !important;\n}\n\nbody[data-theme=\"dark\"] #custom-model-dropdown div:hover {\n    background-color: #4a5568 !important;\n}\n\n/* Dark mode button adjustments for Managed Models */\nbody[data-theme=\"dark\"] .set-individual-quota {\n    color: #90cdf4 !important; /* Lighter blue */\n}\nbody[data-theme=\"dark\"] .set-individual-quota:hover {\n    color: #bee3f8 !important; /* Even lighter blue on hover */\n}\nbody[data-theme=\"dark\"] .delete-model {\n    color: #fbb6ce !important; /* Lighter red/pink */\n}\nbody[data-theme=\"dark\"] .delete-model:hover {\n    color: #fecaca !important; /* Even lighter red/pink on hover */\n}\n\n/* Dark mode adjustments for text-based delete buttons */\nbody[data-theme=\"dark\"] .delete-worker-key,\nbody[data-theme=\"dark\"] .delete-gemini-key { /* .delete-model already covered above */\n    color: #fbb6ce !important; /* Lighter red/pink */\n}\nbody[data-theme=\"dark\"] .delete-worker-key:hover,\nbody[data-theme=\"dark\"] .delete-gemini-key:hover { /* .delete-model already covered above */\n    color: #fecaca !important; /* Even lighter red/pink on hover */\n}\n\n/* Dark mode adjustments for text-based action buttons */\nbody[data-theme=\"dark\"] .test-gemini-key { /* .set-individual-quota already covered above */\n     color: #90cdf4 !important; /* Lighter blue */\n}\nbody[data-theme=\"dark\"] .test-gemini-key:hover { /* .set-individual-quota already covered above */\n     color: #bee3f8 !important; /* Even lighter blue on hover */\n}\n\n/* Adjust Generate Worker Key button (text-based) */\nbody[data-theme=\"dark\"] #generate-worker-key {\n    color: #a3bffa !important; /* Lighter indigo */\n}\nbody[data-theme=\"dark\"] #generate-worker-key:hover {\n    color: #c3dafe !important; /* Even lighter indigo */\n}\n\n/* Adjust Set Category Quotas button (background) */\nbody[data-theme=\"dark\"] #set-category-quotas-btn {\n    background-color: #4299e1 !important; /* Brighter blue */\n    color: #ffffff !important;\n}\nbody[data-theme=\"dark\"] #set-category-quotas-btn:hover {\n    background-color: #63b3ed !important; /* Lighter blue on hover */\n}\n\n/* Adjust Run Test button (background) */\nbody[data-theme=\"dark\"] .run-test-btn {\n    background-color: #48bb78 !important; /* Brighter green */\n    color: #ffffff !important;\n}\nbody[data-theme=\"dark\"] .run-test-btn:hover {\n    background-color: #68d391 !important; /* Lighter green on hover */\n}\n\n/* Adjust Modal Cancel buttons (background) */\nbody[data-theme=\"dark\"] #cancel-category-quotas,\nbody[data-theme=\"dark\"] #cancel-individual-quota {\n    background-color: #4a5568 !important; /* Darker gray background */\n    color: #e2e8f0 !important; /* Light text */\n    border-color: #718096 !important; /* Gray border */\n}\nbody[data-theme=\"dark\"] #cancel-category-quotas:hover,\nbody[data-theme=\"dark\"] #cancel-individual-quota:hover {\n    background-color: #718096 !important; /* Lighter gray background on hover */\n}\n\n/* Dark mode adjustments for Ignore Error button */\nbody[data-theme=\"dark\"] .clear-gemini-key-error {\n    color: #faf089 !important; /* Lighter yellow (Tailwind yellow-300) */\n}\nbody[data-theme=\"dark\"] .clear-gemini-key-error:hover {\n    color: #f6e05e !important;\n}\n\n/* Dark mode adjustments for Vertex configuration buttons */\nbody[data-theme=\"dark\"] #clear-vertex-config {\n    color: #fbb6ce !important; /* Lighter red/pink */\n    border-color: #f56565 !important; /* Red border */\n}\nbody[data-theme=\"dark\"] #clear-vertex-config:hover {\n    background-color: #742a2a !important; /* Dark red background on hover */\n    color: #fecaca !important; /* Even lighter red/pink on hover */\n}\n\n/* Dark mode adjustments for radio buttons */\nbody[data-theme=\"dark\"] input[type=\"radio\"] {\n    background-color: #2d3748 !important;\n    border-color: #4a5568 !important;\n}\n\nbody[data-theme=\"dark\"] input[type=\"radio\"]:checked {\n    background-color: #3182ce !important;\n    border-color: #3182ce !important;\n}\n\nbody[data-theme=\"dark\"] input[type=\"radio\"]:focus {\n    box-shadow: 0 0 0 2px #3182ce !important;\n}\n\n/* Dark mode adjustments for status badges */\nbody[data-theme=\"dark\"] .bg-green-100.text-green-800 {\n    background-color: #065f46 !important;\n    color: #d1fae5 !important;\n}\n\nbody[data-theme=\"dark\"] .bg-gray-100.text-gray-800 {\n    background-color: #374151 !important;\n    color: #d1d5db !important;\n}\n\n/* Update Notifier Styles */\n#update-notifier {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    min-width: 32px;\n    height: 18px;\n    background: linear-gradient(135deg, #ff4444, #ff6666);\n    border-radius: 9px;\n    margin-left: 12px;\n    vertical-align: text-top;\n    cursor: pointer;\n    transition: all 0.3s ease-in-out;\n    font-size: 10px;\n    font-weight: bold;\n    color: white;\n    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);\n    box-shadow: 0 2px 4px rgba(255, 68, 68, 0.3);\n    position: relative;\n    transform: translateY(-2px);\n    padding: 0 6px;\n}\n\n/* Version Display Styles (when no update available) */\n#update-notifier.version-display {\n    background: linear-gradient(135deg, #3b82f6, #60a5fa) !important;\n    box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3) !important;\n    min-width: auto;\n    padding: 0 8px;\n}\n\n#update-notifier.version-display:hover {\n    transform: translateY(-2px) scale(1.05);\n    box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);\n}\n\n#update-notifier:not(.version-display):hover {\n    transform: translateY(-2px) scale(1.1);\n    box-shadow: 0 4px 8px rgba(255, 68, 68, 0.4);\n}\n\n#update-notifier.hidden {\n    display: none;\n}\n\n/* Update Notifier Tooltip */\n#update-notifier::after {\n    content: attr(data-tooltip);\n    position: absolute;\n    top: 100%;\n    left: 50%;\n    transform: translateX(-50%);\n    background-color: #333;\n    color: white;\n    padding: 8px 12px;\n    border-radius: 6px;\n    font-size: 12px;\n    font-weight: normal;\n    white-space: nowrap;\n    opacity: 0;\n    visibility: hidden;\n    transition: all 0.3s ease-in-out;\n    z-index: 1000;\n    margin-top: 5px;\n    text-shadow: none;\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);\n}\n\n#update-notifier:hover::after {\n    opacity: 1;\n    visibility: visible;\n}\n"
  },
  {
    "path": "public/admin/version.txt",
    "content": "1.3.4"
  },
  {
    "path": "public/i18n.js",
    "content": "// 国际化配置和管理脚本\nclass I18n {\n    constructor() {\n        this.currentLanguage = 'zh'; // 默认中文\n        this.translations = {\n            zh: {\n                // 登录页面\n                'password': '密码',\n                'enter_admin_password': '请输入管理员密码',\n                'login': '登录',\n                \n                // 通用\n                'loading': '加载中...',\n                'error': '错误',\n                'success': '成功',\n                'cancel': '取消',\n                'save': '保存',\n                'delete': '删除',\n                'edit': '编辑',\n                'add': '添加',\n                'test': '测试',\n                'generate': '生成',\n                'optional': '可选',\n                'required': '必填',\n                \n                // 认证相关\n                'verifying_identity': '正在验证身份...',\n                'auth_check_timeout': '如果页面长时间无响应，请',\n                'return_to_login': '返回登录页面',\n                'unauthorized_access': '未授权访问',\n                'need_login_message': '您需要登录才能访问管理页面。',\n                'go_to_login': '前往登录',\n                'logout': '退出登录',\n                \n                // Gemini API Keys 部分\n                'add_new_gemini_key': '添加新的 Gemini 密钥',\n                'name_optional': '名称（可选）',\n                'name_placeholder': '例如：个人密钥',\n                'name_help': '用于识别的友好名称。如果未提供，将使用自动生成的ID。',\n                'api_key_value': 'API 密钥值',\n                'enter_gemini_api_key': '请输入 Gemini API 密钥',\n                'gemini_key_batch_help': '支持批量添加：使用逗号分隔多个密钥，或每行一个密钥。',\n                'add_gemini_key': '添加 Gemini 密钥',\n                'run_all_test': '运行所有测试',\n                'ignore_all_errors': '忽略所有报错',\n                'clean_error_keys': '清理报错密钥',\n                'loading_keys': '加载密钥中...',\n\n                // Vertex AI 配置部分\n                'current_vertex_config': '当前 Vertex 配置',\n                'loading_vertex_config': '加载配置中...',\n                'vertex_config_title': 'Vertex AI 配置',\n                'auth_mode': '认证模式',\n                'express_mode': '快捷模式 (API Key)',\n                'service_account_mode': '服务账号 (JSON)',\n                'express_api_key': 'Express API Key',\n                'enter_express_api_key': '请输入 Vertex AI Express API Key',\n                'express_api_key_help': '用于 Vertex AI Express Mode 的 API Key',\n                'service_account_json': 'Service Account JSON',\n                'enter_vertex_json': '请输入 Vertex AI Service Account JSON',\n                'vertex_json_help': '完整的 Google Cloud Service Account JSON 配置',\n                'save_vertex_config': '保存 Vertex 配置',\n                'test_vertex_config': '测试配置',\n                'clear_vertex_config': '清除配置',\n                'no_vertex_config': '未配置 Vertex AI',\n                'project_id': '项目 ID',\n                'client_email': '客户端邮箱',\n                'api_key': 'API Key',\n                'invalid_json': '无效的 JSON 配置',\n                'vertex_config_overwrite_confirm': '检测到已存在 Vertex 配置，是否要覆盖当前配置？',\n                'clean_error_keys_confirm': '确定要删除所有带错误标记的 Gemini 密钥吗？此操作不可撤销。',\n                'ignore_all_errors_confirm': '确定要清除所有带错误标记的 Gemini 密钥的错误状态吗？密钥将保留但错误标记会被移除。',\n                'no_error_keys_found': '没有找到带错误标记的密钥。',\n                'error_keys_cleaned': '成功清理了 {0} 个报错密钥。',\n                'error_keys_ignored': '成功忽略了 {0} 个报错密钥的错误状态。',\n                'failed_to_clean_error_keys': '清理报错密钥失败：{0}',\n                'failed_to_ignore_error_keys': '忽略报错密钥失败：{0}',\n                \n                // Worker API Keys 部分\n                'add_new_worker_key': '添加新的 Worker 密钥',\n                'generate_or_enter_key': '生成或输入强密钥',\n                'generate_random_key': '生成随机密钥',\n                'description_optional': '描述（可选）',\n                'description_placeholder': '例如：客户端应用 A',\n                'add_worker_key': '添加 Worker 密钥',\n                'safety_settings_help': '安全设置：默认启用。禁用时，模型允许生成 NSFW 内容。',\n                \n                // Models 部分\n                'add_model': '添加模型',\n                'model_id': '模型 ID',\n                'model_id_placeholder': '例如：gemini-1.5-flash-latest',\n                'model_id_help': '选择或输入模型 ID',\n                'category': '类别',\n                'daily_quota_custom': '每日配额（自定义）',\n                'quota_placeholder': '例如：1500，或 \\'none\\'/\\'0\\' 表示无限制',\n                'quota_help': '仅适用于\"自定义\"类别。设置此特定模型的每日最大请求数。输入 \\'none\\' 或 \\'0\\' 表示无限制。',\n                'set_category_quotas': '设置类别配额',\n                'loading_models': '加载模型中...',\n                \n                // 配额设置模态框\n                'set_category_quotas_title': '设置类别配额',\n                'pro_models_daily_quota': 'Pro 模型每日配额',\n                'flash_models_daily_quota': 'Flash 模型每日配额',\n                'save_quotas': '保存配额',\n                \n                // 独立配额模态框\n                'set_quota': '设置配额',\n                'individual_daily_quota': '独立配额',\n                'individual_quota_placeholder': '默认：0（无独立配额）',\n                'individual_quota_help': '输入 0 表示无独立配额。独立配额将覆盖类别配额。',\n                'save_quota': '保存配额',\n                \n                // 测试进度\n                'running_all_tests': '正在运行所有测试',\n                'progress': '进度',\n                'preparing_tests': '准备测试中...',\n                \n                // 按钮和操作\n                'set_individual_quota': '设置独立配额',\n                'ignore_error': '忽略错误',\n                'clear_error': '清除错误',\n\n                // 弹窗和动态内容\n                'id': 'ID',\n                'key_preview': '密钥预览',\n                'total_usage_today': '今日总使用量',\n                'date': '日期',\n                'error_status': '错误状态',\n                'category_usage': '类别使用情况',\n                'pro_models': 'Pro 模型',\n                'flash_models': 'Flash 模型',\n                'test_api_key': '测试 API 密钥',\n                'select_a_model': '选择一个模型...',\n                'run_test': '运行测试',\n                'testing': '测试中...',\n                'test_passed': '测试通过！',\n                'test_failed': '测试失败。',\n                'status': '状态',\n                'response': '响应',\n                'no_description': '无描述',\n                'created': '创建于',\n                'safety_settings': '安全设置',\n                'enabled': '已启用',\n                'disabled': '已禁用',\n                'unlimited': '无限制',\n                'individual_quota': '独立配额',\n                'quota': '配额',\n                'set_quota_btn': '设置配额',\n                'delete_confirm_gemini': '您确定要删除 Gemini 密钥 ID：{0} 吗？',\n                'delete_confirm_worker': '您确定要删除 Worker 密钥：{0} 吗？',\n                'delete_confirm_model': '您确定要删除模型：{0} 吗？',\n                'please_select_model': '请选择一个模型进行测试',\n                'tests_cancelled': '测试已被用户取消',\n                'all_tests_completed': '所有测试已完成！',\n                'completed_testing': '已完成测试 {0} 个 Gemini 密钥，使用模型 {1}。',\n                'test_run_failed': '测试运行失败',\n                'cancelling_tests': '正在取消测试...',\n                'testing_batch': '正在测试批次 {0}...',\n                'completed_tests': '已完成 {0} / {1} 测试',\n                'no_gemini_keys_found': '未找到要测试的 Gemini 密钥。',\n                'network_error': '网络错误',\n                'test_failed_no_response': '测试失败：服务器无响应',\n                'test_failed_network': '测试失败：网络错误 - {0}',\n                'unknown_error': '未知错误',\n                'test_run_cancelled': '测试运行已取消。',\n                'failed_to_run_tests': '运行所有测试失败：{0}',\n\n                // 系统设置\n                'system_settings': '系统设置',\n                'keepalive_setting': 'KEEPALIVE 模式',\n                'keepalive_description': '启用后将使用保持连接模式处理请求',\n                'max_retry_setting': '最大重试次数',\n                'max_retry_description': 'API请求失败时的最大重试次数（默认：3）',\n                'web_search_setting': '联网搜索',\n                'web_search_description': '启用后将在模型列表中显示带-search后缀的联网搜索模型',\n                'auto_test_setting': '自动批量测试',\n                'auto_test_description': '启用后将在每天北京时间4点自动进行批量测试',\n                'update_available': '有可用的新版本',\n                'current_is_latest': '当前为最新版本'\n             },\n             en: {\n                 // 登录页面\n                 'password': 'Password',\n                'enter_admin_password': 'Enter admin password',\n                'login': 'Login',\n                \n                // 通用\n                'loading': 'Loading...',\n                'error': 'Error',\n                'success': 'Success',\n                'cancel': 'Cancel',\n                'save': 'Save',\n                'delete': 'Delete',\n                'edit': 'Edit',\n                'add': 'Add',\n                'test': 'Test',\n                'generate': 'Generate',\n                'optional': 'Optional',\n                'required': 'Required',\n                \n                // 认证相关\n                'verifying_identity': 'Verifying identity...',\n                'auth_check_timeout': 'If the page doesn\\'t respond for a long time, please',\n                'return_to_login': 'return to the login page',\n                'unauthorized_access': 'Unauthorized Access',\n                'need_login_message': 'You need to log in to access the admin page.',\n                'go_to_login': 'Go to Login',\n                'logout': 'Logout',\n                \n                // Gemini API Keys 部分\n                'add_new_gemini_key': 'Add New Gemini Key',\n                'name_optional': 'Name (Optional)',\n                'name_placeholder': 'e.g., Personal Key',\n                'name_help': 'A friendly name for identification. If not provided, an auto-generated ID will be used.',\n                'api_key_value': 'API Key Value',\n                'enter_gemini_api_key': 'Enter Gemini API Key',\n                'gemini_key_batch_help': 'Batch addition supported: Use commas to separate multiple keys, or one key per line.',\n                'add_gemini_key': 'Add Gemini Key',\n                'run_all_test': 'Run All Test',\n                'ignore_all_errors': 'Ignore All Errors',\n                'clean_error_keys': 'Clean Error Keys',\n                'loading_keys': 'Loading keys...',\n                'clean_error_keys_confirm': 'Are you sure you want to delete all Gemini keys with error status? This action cannot be undone.',\n                'ignore_all_errors_confirm': 'Are you sure you want to clear error status for all Gemini keys with errors? Keys will be kept but error marks will be removed.',\n                'no_error_keys_found': 'No keys with error status found.',\n                'error_keys_cleaned': 'Successfully cleaned {0} error keys.',\n                'error_keys_ignored': 'Successfully ignored error status for {0} keys.',\n                'failed_to_clean_error_keys': 'Failed to clean error keys: {0}',\n                'failed_to_ignore_error_keys': 'Failed to ignore error keys: {0}',\n\n                // Vertex AI Configuration\n                'current_vertex_config': 'Current Vertex Configuration',\n                'loading_vertex_config': 'Loading configuration...',\n                'vertex_config_title': 'Vertex AI Configuration',\n                'auth_mode': 'Authentication Mode',\n                'express_mode': 'Express Mode (API Key)',\n                'service_account_mode': 'Service Account (JSON)',\n                'express_api_key': 'Express API Key',\n                'enter_express_api_key': 'Enter Vertex AI Express API Key',\n                'express_api_key_help': 'API Key for Vertex AI Express Mode',\n                'service_account_json': 'Service Account JSON',\n                'enter_vertex_json': 'Enter Vertex AI Service Account JSON',\n                'vertex_json_help': 'Complete Google Cloud Service Account JSON configuration',\n                'save_vertex_config': 'Save Vertex Configuration',\n                'test_vertex_config': 'Test Configuration',\n                'clear_vertex_config': 'Clear Configuration',\n                'no_vertex_config': 'Vertex AI not configured',\n                'project_id': 'Project ID',\n                'client_email': 'Client Email',\n                'api_key': 'API Key',\n                'invalid_json': 'Invalid JSON configuration',\n                'vertex_config_overwrite_confirm': 'Existing Vertex configuration detected. Do you want to overwrite the current configuration?',\n\n                // Worker API Keys 部分\n                'add_new_worker_key': 'Add New Worker Key',\n                'generate_or_enter_key': 'Generate or enter a strong key',\n                'generate_random_key': 'Generate Random Key',\n                'description_optional': 'Description (Optional)',\n                'description_placeholder': 'e.g., Client App A',\n                'add_worker_key': 'Add Worker Key',\n                'safety_settings_help': 'Safety Settings: Enabled by default. When disabled, the model is allowed to generate NSFW content.',\n                \n                // Models 部分\n                'add_model': 'Add Model',\n                'model_id': 'Model ID',\n                'model_id_placeholder': 'e.g., gemini-1.5-flash-latest',\n                'model_id_help': 'Select or enter model ID',\n                'category': 'Category',\n                'daily_quota_custom': 'Daily Quota (Custom)',\n                'quota_placeholder': 'e.g., 1500, or \\'none\\'/\\'0\\' for unlimited',\n                'quota_help': 'Only for \\'Custom\\' category. Sets max daily requests for this specific model. Enter \\'none\\' or \\'0\\' for unlimited.',\n                'set_category_quotas': 'Set Category Quotas',\n                'loading_models': 'Loading models...',\n                \n                // 配额设置模态框\n                'set_category_quotas_title': 'Set Category Quotas',\n                'pro_models_daily_quota': 'Pro Models Daily Quota',\n                'flash_models_daily_quota': 'Flash Models Daily Quota',\n                'save_quotas': 'Save Quotas',\n                \n                // 独立配额模态框\n                'set_quota': 'Set Quota',\n                'individual_daily_quota': 'Individual Daily Quota',\n                'individual_quota_placeholder': 'Default: 0 (No individual quota)',\n                'individual_quota_help': 'Enter 0 for no individual quota. Individual quota is applied in addition to category quota.',\n                'save_quota': 'Save Quota',\n                \n                // 测试进度\n                'running_all_tests': 'Running All Tests',\n                'progress': 'Progress',\n                'preparing_tests': 'Preparing tests...',\n                \n                // 按钮和操作\n                'set_individual_quota': 'Set Individual Quota',\n                'ignore_error': 'Ignore Error',\n                'clear_error': 'Clear Error',\n\n                // 弹窗和动态内容\n                'id': 'ID',\n                'key_preview': 'Key Preview',\n                'total_usage_today': 'Total Usage Today',\n                'date': 'Date',\n                'error_status': 'Error Status',\n                'category_usage': 'Category Usage',\n                'pro_models': 'Pro Models',\n                'flash_models': 'Flash Models',\n                'test_api_key': 'Test API Key',\n                'select_a_model': 'Select a model...',\n                'run_test': 'Run Test',\n                'testing': 'Testing...',\n                'test_passed': 'Test Passed!',\n                'test_failed': 'Test Failed.',\n                'status': 'Status',\n                'response': 'Response',\n                'no_description': 'No description',\n                'created': 'Created',\n                'safety_settings': 'Safety Settings',\n                'enabled': 'Enabled',\n                'disabled': 'Disabled',\n                'unlimited': 'Unlimited',\n                'individual_quota': 'Individual Quota',\n                'quota': 'Quota',\n                'set_quota_btn': 'Set Quota',\n                'delete_confirm_gemini': 'Are you sure you want to delete Gemini key with ID: {0}?',\n                'delete_confirm_worker': 'Are you sure you want to delete Worker key: {0}?',\n                'delete_confirm_model': 'Are you sure you want to delete model: {0}?',\n                'please_select_model': 'Please select a model to test',\n                'tests_cancelled': 'Tests cancelled by user',\n                'all_tests_completed': 'All tests completed!',\n                'completed_testing': 'Completed testing {0} Gemini keys with model {1}.',\n                'test_run_failed': 'Test run failed',\n                'cancelling_tests': 'Cancelling tests...',\n                'testing_batch': 'Testing batch {0}...',\n                'completed_tests': 'Completed {0} of {1} tests',\n                'no_gemini_keys_found': 'No Gemini keys found to test.',\n                'network_error': 'Network error',\n                'test_failed_no_response': 'Test failed: No response from server',\n                'test_failed_network': 'Test failed: Network error - {0}',\n                'unknown_error': 'Unknown error',\n                'test_run_cancelled': 'Test run was cancelled.',\n                'failed_to_run_tests': 'Failed to run all tests: {0}',\n\n                // System Settings\n                'system_settings': 'System Settings',\n                'keepalive_setting': 'KEEPALIVE Mode',\n                'keepalive_description': 'Enable keep-alive mode for request processing',\n                'max_retry_setting': 'Max Retry Count',\n                'max_retry_description': 'Maximum retry attempts for failed API requests (default: 3)',\n                'web_search_setting': 'Web Search',\n                'web_search_description': 'Enable to show models with -search suffix for web search functionality',\n                'auto_test_setting': 'Auto Batch Test',\n                'auto_test_description': 'Enable to automatically run batch tests daily at 4 AM Beijing time',\n                'update_available': 'A new version is available',\n                'current_is_latest': 'Current is latest version'\n             }\n         };\n         \n         this.init();\n    }\n    \n    init() {\n        // 检测浏览器语言\n        this.detectLanguage();\n\n        // 应用翻译\n        this.applyTranslations();\n\n        // 监听语言变化\n        this.setupLanguageChangeListener();\n    }\n    \n    detectLanguage() {\n        // 检测浏览器语言，不再使用本地存储\n        const browserLanguage = navigator.language || navigator.userLanguage;\n        if (browserLanguage.startsWith('zh')) {\n            this.currentLanguage = 'zh';\n        } else {\n            this.currentLanguage = 'en';\n        }\n    }\n    \n\n    \n    translate(key, ...args) {\n        let translation = this.translations[this.currentLanguage][key] || key;\n\n        // 支持参数替换，例如 translate('message', 'arg1', 'arg2')\n        if (args.length > 0) {\n            args.forEach((arg, index) => {\n                translation = translation.replace(`{${index}}`, arg);\n            });\n        }\n\n        return translation;\n    }\n    \n    applyTranslations() {\n        // 翻译所有带有 data-i18n 属性的元素\n        document.querySelectorAll('[data-i18n]').forEach(element => {\n            const key = element.getAttribute('data-i18n');\n            const translation = this.translate(key);\n            \n            if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) {\n                element.placeholder = translation;\n            } else {\n                element.textContent = translation;\n            }\n        });\n        \n        // 翻译所有带有 data-i18n-placeholder 属性的元素\n        document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {\n            const key = element.getAttribute('data-i18n-placeholder');\n            element.placeholder = this.translate(key);\n        });\n        \n        // 翻译所有带有 data-i18n-title 属性的元素\n        document.querySelectorAll('[data-i18n-title]').forEach(element => {\n            const key = element.getAttribute('data-i18n-title');\n            element.title = this.translate(key);\n        });\n    }\n    \n    setupLanguageChangeListener() {\n        // 监听DOM变化，自动翻译新添加的元素\n        const observer = new MutationObserver((mutations) => {\n            mutations.forEach((mutation) => {\n                mutation.addedNodes.forEach((node) => {\n                    if (node.nodeType === Node.ELEMENT_NODE) {\n                        // 翻译新添加的元素\n                        if (node.hasAttribute && node.hasAttribute('data-i18n')) {\n                            const key = node.getAttribute('data-i18n');\n                            const translation = this.translate(key);\n                            \n                            if (node.tagName === 'INPUT' && (node.type === 'text' || node.type === 'password')) {\n                                node.placeholder = translation;\n                            } else {\n                                node.textContent = translation;\n                            }\n                        }\n                        \n                        // 翻译新添加元素的子元素\n                        if (node.querySelectorAll) {\n                            node.querySelectorAll('[data-i18n]').forEach(element => {\n                                const key = element.getAttribute('data-i18n');\n                                const translation = this.translate(key);\n                                \n                                if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) {\n                                    element.placeholder = translation;\n                                } else {\n                                    element.textContent = translation;\n                                }\n                            });\n                            \n                            node.querySelectorAll('[data-i18n-placeholder]').forEach(element => {\n                                const key = element.getAttribute('data-i18n-placeholder');\n                                element.placeholder = this.translate(key);\n                            });\n                            \n                            node.querySelectorAll('[data-i18n-title]').forEach(element => {\n                                const key = element.getAttribute('data-i18n-title');\n                                element.title = this.translate(key);\n                            });\n                        }\n                    }\n                });\n            });\n        });\n        \n        observer.observe(document.body, {\n            childList: true,\n            subtree: true\n        });\n    }\n}\n\n// 页面加载完成后初始化国际化\ndocument.addEventListener('DOMContentLoaded', () => {\n    window.i18n = new I18n();\n});\n\n// 全局翻译函数，方便在其他脚本中使用\nwindow.t = function(key, ...args) {\n    if (window.i18n) {\n        return window.i18n.translate(key, ...args);\n    }\n    return key;\n};\n"
  },
  {
    "path": "public/login.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Admin Login</title>\n    <link rel=\"icon\" href=\"/favicon.ico\" type=\"image/x-icon\">\n    <script src=\"https://cdn.tailwindcss.com?plugins=forms\"></script>\n    <script src=\"i18n.js\"></script>\n    <style>\n        /* Simple loading spinner */\n        .loader {\n            border: 4px solid #f3f3f3; /* Light grey */\n            border-top: 4px solid #3498db; /* Blue */\n            border-radius: 50%;\n            width: 20px;\n            height: 20px;\n            animation: spin 1s linear infinite;\n            display: inline-block;\n            margin-left: 8px;\n            vertical-align: middle;\n        }\n        @keyframes spin {\n            0% { transform: rotate(0deg); }\n            100% { transform: rotate(360deg); }\n        }\n\n        /* Light mode warm background colors */\n        body {\n            background-color: #f5f3f0 !important; /* Warm light khaki background */\n        }\n\n        .bg-white {\n            background-color: #faf9f7 !important; /* Slightly warmer white for login form */\n        }\n\n        /* Input field adjustments for warm theme */\n        input[type=\"password\"] {\n            background-color: #f8f6f3 !important; /* Warm input background */\n            border-color: #e6ddd4 !important; /* Warmer border */\n        }\n\n        input[type=\"password\"]:focus {\n            background-color: #faf9f7 !important; /* Slightly lighter when focused */\n            border-color: #4f46e5 !important; /* Keep indigo focus border */\n            box-shadow: 0 0 0 1px #4f46e5 !important;\n        }\n    </style>\n</head>\n<body class=\"bg-gray-100 flex items-center justify-center h-screen\">\n\n    <div class=\"bg-white p-8 rounded-lg shadow-md w-full max-w-sm\">\n        <h1 class=\"text-2xl font-bold mb-6 text-center text-gray-800\">JimiHub</h1>\n\n        <!-- Error Message Area -->\n        <div id=\"error-message\" class=\"bg-red-100 border border-red-400 text-red-700 px-4 py-2 rounded relative mb-4 text-sm hidden\" role=\"alert\">\n            <span id=\"error-text\"></span>\n        </div>\n\n        <form id=\"login-form\">\n            <div class=\"mb-4\">\n                <label for=\"password\" class=\"block text-sm font-medium text-gray-700 mb-1\" data-i18n=\"password\">密码</label>\n                <input type=\"password\" id=\"password\" name=\"password\" required\n                       class=\"mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm\"\n                       placeholder=\"请输入管理员密码\" data-i18n-placeholder=\"enter_admin_password\">\n            </div>\n\n            <button type=\"submit\" id=\"login-button\"\n                    class=\"w-full inline-flex justify-center items-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50\">\n                <span data-i18n=\"login\">登录</span>\n                <div id=\"loading-spinner\" class=\"loader hidden\"></div>\n            </button>\n        </form>\n    </div>\n\n    <script src=\"login.script.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "public/login.script.js",
    "content": "document.addEventListener('DOMContentLoaded', () => {\n    const loginForm = document.getElementById('login-form');\n    const passwordInput = document.getElementById('password');\n    const loginButton = document.getElementById('login-button');\n    const loadingSpinner = document.getElementById('loading-spinner');\n    const errorMessageDiv = document.getElementById('error-message');\n    const errorTextSpan = document.getElementById('error-text');\n    \n    function showLoading() {\n        loginButton.disabled = true;\n        loadingSpinner.classList.remove('hidden');\n    }\n    \n    function hideLoading() {\n        loginButton.disabled = false;\n        loadingSpinner.classList.add('hidden');\n    }\n    \n    function showError(message) {\n        errorTextSpan.textContent = message;\n        errorMessageDiv.classList.remove('hidden');\n    }\n    \n    function hideError() {\n        errorMessageDiv.classList.add('hidden');\n        errorTextSpan.textContent = '';\n    }\n    \n    loginForm.addEventListener('submit', async (e) => {\n        e.preventDefault();\n        hideError();\n        showLoading();\n        const password = passwordInput.value;\n        \n        try {\n            const response = await fetch('/api/login', {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json',\n                },\n                body: JSON.stringify({ password }),\n            });\n            \n            const data = await response.json();\n            \n            if (response.ok && data.success) {\n                localStorage.setItem('isLoggedIn', 'true');\n                window.location.href = '/admin';\n            } else {\n                showError(data.error || 'Login failed. Please check your password.');\n                passwordInput.focus();\n            }\n        } catch (error) {\n            console.error('Login Error:', error);\n            showError('An error occurred during login. Please try again.');\n        } finally {\n            hideLoading();\n        }\n    });\n});"
  },
  {
    "path": "src/db/index.js",
    "content": "const sqlite3 = require('sqlite3').verbose();\nconst path = require('path');\nconst fs = require('fs');\nconst GitHubSync = require('../utils/githubSync');\n\n// Construct the database path\nlet dataDir;\n\n// Use /home/user/data directory on Hugging Face Space\nif (process.env.HUGGING_FACE === '1') {\n  dataDir = '/home/user/data';\n  console.log(`Using Hugging Face persistent data directory: ${dataDir}`);\n} else {\n  dataDir = path.resolve(__dirname, '..', '..', 'data');\n}\n\nif (!fs.existsSync(dataDir)) {\n  console.log(`Creating data directory: ${dataDir}`);\n  try {\n    fs.mkdirSync(dataDir, { recursive: true });\n  } catch (err) {\n    console.error(`Error creating data directory: ${err.message}`);\n    console.error('Will attempt to use ./data as fallback');\n    dataDir = path.resolve(__dirname, '..', '..', 'data');\n    if (!fs.existsSync(dataDir)) {\n      fs.mkdirSync(dataDir, { recursive: true });\n    }\n  }\n}\n\nconst dbPath = path.resolve(dataDir, 'database.db');\nconsole.log(`Database path: ${dbPath}`); // Log the path for debugging\n\n// Initialize GitHub sync if configured\nconst githubProject = process.env.GITHUB_PROJECT;\nconst githubToken = process.env.GITHUB_PROJECT_PAT;\nconst githubEncryptKey = process.env.GITHUB_ENCRYPT_KEY;\nlet githubSync = null;\n\nif (githubProject && githubToken) {\n  console.log(`GitHub sync configured for repository: ${githubProject}`);\n  githubSync = new GitHubSync(githubProject, githubToken, dbPath, githubEncryptKey);\n  \n  if (githubEncryptKey && githubEncryptKey.length >= 32) {\n    console.log('GitHub data encryption enabled, using AES-256-CBC algorithm');\n  } else if (githubEncryptKey) {\n    console.warn('GitHub encryption key length is insufficient, requires at least 32 characters, data will be stored unencrypted');\n  } else {\n    console.log('GitHub data encryption not enabled, data will be stored unencrypted');\n  }\n}\n\n// Function to validate if a file is a valid SQLite database\nfunction validateDatabaseFile(filePath) {\n  try {\n    if (!fs.existsSync(filePath)) {\n      return { valid: false, reason: 'File does not exist' };\n    }\n\n    const buffer = fs.readFileSync(filePath, { encoding: null });\n    if (buffer.length < 16) {\n      return { valid: false, reason: 'File too small to be a valid SQLite database' };\n    }\n\n    // Check SQLite file header\n    const sqliteHeader = Buffer.from(\"SQLite format 3\\0\");\n    const fileHeader = buffer.subarray(0, 16);\n\n    if (Buffer.compare(fileHeader, sqliteHeader) === 0) {\n      return { valid: true, reason: 'Valid SQLite database' };\n    } else {\n      return { valid: false, reason: 'Invalid SQLite header' };\n    }\n  } catch (error) {\n    return { valid: false, reason: `Error reading file: ${error.message}` };\n  }\n}\n\n// Initialize database with proper GitHub sync handling\nasync function initializeDatabase() {\n  // Try to download database from GitHub BEFORE opening the database connection\n  if (githubSync) {\n    try {\n      console.log('Attempting to download database from GitHub before opening connection...');\n      const downloadSuccess = await githubSync.downloadDatabase();\n\n      // Validate the downloaded database file\n      if (downloadSuccess && fs.existsSync(dbPath)) {\n        const validation = validateDatabaseFile(dbPath);\n        if (!validation.valid) {\n          console.warn(`Downloaded database file is invalid: ${validation.reason}`);\n          console.log('Removing invalid database file and creating a new one...');\n          try {\n            fs.unlinkSync(dbPath);\n          } catch (unlinkErr) {\n            console.error('Failed to remove invalid database file:', unlinkErr.message);\n          }\n        } else {\n          console.log('Downloaded database file validation passed');\n        }\n      }\n    } catch (err) {\n      console.error('Failed to download database from GitHub:', err.message);\n      console.log('Continuing with local database...');\n    }\n  }\n\n  return new Promise((resolve, reject) => {\n    // Initialize the database connection after GitHub sync is complete\n    // The OPEN_READWRITE | OPEN_CREATE flag ensures the file is created if it doesn't exist.\n    const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, async (err) => {\n      if (err) {\n        console.error('Error opening database:', err.message);\n        reject(err); // Reject to stop the application if DB connection fails\n      } else {\n        console.log('Connected to the SQLite database.');\n\n        // Initialize database schema\n        try {\n          await new Promise((schemaResolve, schemaReject) => {\n            // Pass the current database instance to the schema initialization function\n            initializeDatabaseSchemaInternal.call({ db: db }, (schemaErr) => {\n              if (schemaErr) schemaReject(schemaErr);\n              else schemaResolve();\n            });\n          });\n\n          resolve(db);\n        } catch (schemaErr) {\n          console.error('Failed to initialize database schema:', schemaErr.message);\n          reject(schemaErr);\n        }\n      }\n    });\n  });\n}\n\n// Start the database initialization\nlet db;\ninitializeDatabase()\n  .then(async (database) => {\n    db = database;\n    console.log('Database initialization completed successfully.');\n\n    // Initialize Vertex service after database is ready and exported\n    try {\n      const vertexService = require('../services/vertexProxyService');\n      console.log('Initializing Vertex AI service after database setup...');\n      await vertexService.initializeVertexCredentials();\n    } catch (err) {\n      console.error('Failed to initialize Vertex service:', err.message);\n    }\n\n    // Initialize scheduler service after database is ready\n    try {\n      const schedulerService = require('../services/schedulerService');\n      console.log('Initializing Scheduler Service after database setup...');\n      await schedulerService.initialize();\n      console.log('Scheduler Service: Initialized successfully');\n    } catch (err) {\n      console.error('Scheduler Service: Failed to initialize:', err.message);\n    }\n  })\n  .catch((err) => {\n    console.error('Fatal error during database initialization:', err.message);\n    process.exit(1);\n  });\n\n// Function to trigger GitHub sync (now always delayed)\nasync function syncToGitHub() {\n  if (!githubSync) {\n    return false;\n  }\n\n  try {\n    // Schedule the delayed sync\n    await githubSync.scheduleSync();\n    return true; // Indicate scheduling was successful\n  } catch (err) {\n    console.error('Failed to schedule GitHub sync:', err.message);\n    return false;\n  }\n}\n\n// SQL statements to create tables (if they don't exist)\nconst createTablesSQL = `\n  CREATE TABLE IF NOT EXISTS gemini_keys (\n    id TEXT PRIMARY KEY,\n    api_key TEXT NOT NULL UNIQUE,\n    name TEXT,\n    usage_date TEXT,\n    model_usage TEXT DEFAULT '{}',       -- Store as JSON string\n    category_usage TEXT DEFAULT '{}',    -- Store as JSON string\n    error_status INTEGER,               -- 401, 403, or NULL\n    consecutive_429_counts TEXT DEFAULT '{}', -- Store as JSON string\n    created_at TEXT DEFAULT CURRENT_TIMESTAMP\n  );\n\n  CREATE TABLE IF NOT EXISTS worker_keys (\n    api_key TEXT PRIMARY KEY,\n    description TEXT,\n    safety_enabled INTEGER DEFAULT 1,  -- 1 for true, 0 for false\n    created_at TEXT DEFAULT CURRENT_TIMESTAMP\n  );\n\n  CREATE TABLE IF NOT EXISTS models_config (\n    model_id TEXT PRIMARY KEY,\n    category TEXT NOT NULL CHECK(category IN ('Pro', 'Flash', 'Custom')),\n    daily_quota INTEGER,                -- NULL means unlimited\n    individual_quota INTEGER            -- NULL means no individual limit\n  );\n\n  CREATE TABLE IF NOT EXISTS settings (\n    key TEXT PRIMARY KEY,\n    value TEXT                           -- Can store JSON strings or simple values\n  );\n\n  -- Initialize default category quotas if not present\n  INSERT OR IGNORE INTO settings (key, value) VALUES\n    ('category_quotas', '{\"proQuota\": 50, \"flashQuota\": 1500}');\n\n  -- Initialize gemini_key_list if not present (as an empty JSON array)\n  INSERT OR IGNORE INTO settings (key, value) VALUES\n    ('gemini_key_list', '[]');\n\n  -- Initialize gemini_key_index if not present\n  INSERT OR IGNORE INTO settings (key, value) VALUES\n    ('gemini_key_index', '0');\n\n  -- Add other default settings as needed, e.g., last used key ID\n  INSERT OR IGNORE INTO settings (key, value) VALUES\n    ('last_used_gemini_key_id', '');\n`;\n\n// Function to initialize the database schema\nfunction initializeDatabaseSchemaInternal(callback) {\n  // Use the database instance passed via 'this' context or fall back to global db\n  const currentDb = this?.db || db;\n  if (!currentDb) {\n    const error = new Error('Database instance not available for schema initialization');\n    console.error(error.message);\n    if (callback) callback(error);\n    return;\n  }\n\n  currentDb.exec(createTablesSQL, (err) => {\n    if (err) {\n      console.error('Error creating database tables:', err.message);\n      if (callback) callback(err);\n    } else {\n      console.log('Database tables checked/created successfully.');\n      // You might seed initial data here if necessary\n      if (callback) callback(null);\n    }\n  });\n}\n\n// Function to safely close the database connection\nfunction closeDatabase() {\n  if (db) {\n    db.close((err) => {\n      if (err) {\n        console.error('Error closing database:', err.message);\n      } else {\n        console.log('Database connection closed.');\n      }\n    });\n  }\n}\n\n// Gracefully close the database on application exit\nprocess.on('SIGINT', () => {\n  closeDatabase();\n  process.exit(0);\n});\n\nprocess.on('SIGTERM', () => {\n    closeDatabase();\n    process.exit(0);\n});\n\n// Export the database connection instance and sync functions\nmodule.exports = {\n  get db() { return db; }, // Use getter to ensure db is available when accessed\n  syncToGitHub\n};\n"
  },
  {
    "path": "src/index.js",
    "content": "// Load environment variables from .env file FIRST\nrequire('dotenv').config();\n\nconst express = require('express');\nconst path = require('path');\nconst cors = require('cors');\nconst cookieParser = require('cookie-parser');\n\n// Import the database module (this will also trigger initialization)\nconst dbModule = require('./db');\n\n// Import Vertex service but don't initialize yet - will be done after DB is ready\nconst vertexService = require('./services/vertexProxyService');\n\n// Note: schedulerService is imported lazily in routes/adminApi.js to avoid database initialization issues\n\n// Import route handlers\nconst authRoutes = require('./routes/auth');\nconst adminApiRoutes = require('./routes/adminApi');\nconst apiV1Routes = require('./routes/apiV1');\n\n// Import services and utils (ensure proxyPool is imported to trigger its initialization)\nrequire('./services/geminiProxyService'); // Still need to import this for other initializations if any\nconst proxyPool = require('./utils/proxyPool');\n\n// Import middleware\nconst requireAdminAuth = require('./middleware/adminAuth');\n\nconst app = express();\nconst port = process.env.PORT || 3000; // Default to 3000 if PORT not set\n\n// --- Middleware ---\n\n// Enable CORS for all origins (adjust for production if needed)\napp.use(cors({\n    origin: '*', // Allow all origins for now\n    credentials: true, // Allow cookies for authenticated requests (like admin UI)\n    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],\n    allowedHeaders: ['Content-Type', 'Authorization', 'x-requested-with'],\n    maxAge: 86400 // Cache preflight requests for 1 day\n}));\n\n// Handle OPTIONS preflight requests globally (alternative to handling in each route)\napp.options('*', cors());\n\n// Parse JSON request bodies\napp.use(express.json({ limit: '100mb' }));\n\n// Parse URL-encoded request bodies\napp.use(express.urlencoded({ extended: true, limit: '100mb' }));\n\n// Parse cookies\napp.use(cookieParser());\n\n// Serve static files from the 'public' directory\n// __dirname now refers to the src directory, need to go up one level\napp.use(express.static(path.join(__dirname, '..', 'public')));\n\n// --- Basic Routes ---\n\n// Root route: Redirects to /admin/index.html if logged in, otherwise requireAdminAuth redirects to /login.html\napp.get('/', (req, res) => {\n    res.redirect('/login.html');\n});\n\n// Redirect /login to the static HTML file\napp.get('/login', (req, res) => {\n    res.redirect('/login.html');\n});\n\n// Admin route: Protect the route and serve the static file\napp.get('/admin', requireAdminAuth, (req, res) => {\n    res.redirect('/admin/'); // Redirect to the directory path\n});\n\napp.use('/admin', requireAdminAuth, express.static(path.join(__dirname, '..', 'public', 'admin')));\n\n// --- API Routes ---\napp.use('/api', authRoutes); \napp.use('/api/admin', requireAdminAuth, adminApiRoutes); \napp.use('/v1', apiV1Routes); \n\n// --- Global Error Handler ---\napp.use((err, req, res, next) => {\n    console.error('Unhandled Error:', err.stack || err);\n    res.status(err.status || 500).json({\n        error: {\n            message: err.message || 'Internal Server Error',\n            type: err.type || 'unhandled_error',\n            ...(process.env.NODE_ENV === 'development' && { stack: err.stack })\n        }\n    });\n});\n\n// --- Start Server ---\napp.listen(port, '0.0.0.0', async () => {\n    console.log(`JimiHub (Node.js version) listening on port ${port} (all interfaces)`);\n\n    // Log Proxy Pool Status\n    const proxyStatus = proxyPool.getProxyPoolStatus(); // Get status from proxyPool module\n    if (proxyStatus.enabled) {\n        console.log(`Proxy Pool: Enabled (Loaded ${proxyStatus.count} SOCKS5 proxies)`);\n    } else if (proxyStatus.count > 0 && !proxyStatus.agentLoaded) {\n        console.log(`Proxy Pool: Configured (${proxyStatus.count} proxies) but DISABLED (missing 'socks-proxy-agent' dependency)`);\n    } else {\n        console.log(`Proxy Pool: Disabled (PROXY environment variable not set or contains no valid SOCKS5 proxies)`);\n    }\n    \n    // Log Vertex AI Status using the check function\n    if (vertexService.isVertexEnabled()) {\n        // Check if we're using Express Mode\n        if (process.env.EXPRESS_API_KEY) {\n            console.log(`Vertex AI: Enabled with Express Mode (API Key authentication, additional [v] prefixed models available)`);\n        } else {\n            console.log(`Vertex AI: Enabled (Service Account credentials, additional [v] prefixed models available)`);\n        }\n    } else {\n        console.log(`Vertex AI: Disabled (VERTEX variable and EXPRESS_API_KEY not found or invalid in .env file)`);\n    }\n    \n    // Check if running in Hugging Face Space\n    if (process.env.HUGGING_FACE === '1' && process.env.SPACE_HOST) {\n        const adminUrl = `https://${process.env.SPACE_HOST}/admin`;\n        const endpointUrl = `https://${process.env.SPACE_HOST}/v1`;\n        console.log(`Hugging Face Space Admin UI: ${adminUrl}`);\n        console.log(`Hugging Face Space Endpoint: ${endpointUrl}`);\n    } else {\n        // Fallback for local or other environments\n        const adminUrl = `http://localhost:${port}/admin`;\n        const endpointUrl = `http://localhost:${port}/v1`;\n        console.log(`Admin UI available at: ${adminUrl} (or the server's public address)`);\n        console.log(`API Endpoint available at: ${endpointUrl} (or the server's public address)`);\n    }\n});\n"
  },
  {
    "path": "src/middleware/adminAuth.js",
    "content": "const { verifySessionCookie } = require('../utils/session');\n\n/**\n * Express middleware to protect routes requiring admin authentication.\n * Verifies the session cookie. If invalid or missing, redirects to /login.html.\n * @param {import('express').Request} req\n * @param {import('express').Response} res\n * @param {import('express').NextFunction} next\n */\nasync function requireAdminAuth(req, res, next) {\n    try {\n        const isAuthenticated = await verifySessionCookie(req);\n\n        if (!isAuthenticated) {\n            console.log('AdminAuth Middleware: Session invalid or expired. Redirecting to login.');\n            // Redirect to the login page if not authenticated\n            // Check if the original request was for an API endpoint\n            if (req.originalUrl.startsWith('/api/admin')) {\n                // For API requests, send a 401 Unauthorized status instead of redirecting\n                return res.status(401).json({ error: 'Unauthorized. Please log in again.' });\n            } else {\n                // For page requests (like /admin), redirect to the login page\n                return res.redirect('/login.html');\n            }\n        }\n\n        // If authenticated, proceed to the next middleware or route handler\n        // console.log('AdminAuth Middleware: Session valid.'); // Optional: Log success\n        next();\n    } catch (error) {\n        console.error('Error in admin authentication middleware:', error);\n        // Pass the error to the global error handler\n        next(error);\n    }\n}\n\nmodule.exports = requireAdminAuth;\n"
  },
  {
    "path": "src/middleware/workerAuth.js",
    "content": "const dbModule = require('../db'); // Import the database module\n\n/**\n * Express middleware to validate the Worker API Key provided in the Authorization header.\n * Checks against the `worker_keys` table in the database.\n * @param {import('express').Request} req\n * @param {import('express').Response} res\n * @param {import('express').NextFunction} next\n */\nasync function requireWorkerAuth(req, res, next) {\n    const authHeader = req.headers.authorization;\n    const workerApiKey = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;\n\n    if (!workerApiKey) {\n        return res.status(401).json({ error: 'Missing API key. Provide it in the Authorization header as \"Bearer YOUR_KEY\".' });\n    }\n\n    try {\n        // Get database instance\n        const db = dbModule.db;\n        if (!db) {\n            console.error('Database not available for worker key validation');\n            return res.status(500).json({ error: 'Database not available' });\n        }\n\n        // Query the database to see if the key exists\n        const sql = `SELECT api_key FROM worker_keys WHERE api_key = ?`;\n        db.get(sql, [workerApiKey], (err, row) => {\n            if (err) {\n                console.error('Database error during worker key validation:', err);\n                // Pass error to the global error handler\n                return next(err);\n            }\n\n            if (!row) {\n                // Key not found in the database\n                console.warn(`Worker key validation failed: Key \"${workerApiKey.slice(0, 5)}...\" not found.`);\n                return res.status(401).json({ error: 'Invalid API key.' });\n            }\n\n            // Key is valid, attach it to the request object for potential use later\n            // (e.g., determining safety settings)\n            req.workerApiKey = workerApiKey;\n\n            // Proceed to the next middleware or route handler\n            next();\n        });\n    } catch (error) {\n        console.error('Unexpected error during worker key validation:', error);\n        next(error); // Pass to global error handler\n    }\n}\n\nmodule.exports = requireWorkerAuth;\n"
  },
  {
    "path": "src/routes/adminApi.js",
    "content": "const express = require('express');\nconst requireAdminAuth = require('../middleware/adminAuth');\nconst configService = require('../services/configService');\nconst geminiKeyService = require('../services/geminiKeyService');\nconst vertexProxyService = require('../services/vertexProxyService');\nconst batchTestService = require('../services/batchTestService');\n// Note: schedulerService is imported lazily when needed to avoid database initialization issues\nconst fetch = require('node-fetch');\nconst dbModule = require('../db');\nconst proxyPool = require('../utils/proxyPool'); // Import the proxy pool module\nconst router = express.Router();\n\n// Apply admin authentication middleware to all /api/admin routes\nrouter.use(requireAdminAuth);\n\n// --- Helper for parsing request body (already exists in helpers.js, but useful here) ---\n// Ensure express.json() middleware is applied in server.js\nfunction parseBody(req) {\n    if (!req.body) {\n        throw new Error(\"Request body not parsed. Ensure express.json() middleware is used.\");\n    }\n    return req.body;\n}\n\n// --- Gemini Key Management --- (/api/admin/gemini-keys)\nrouter.route('/gemini-keys')\n    .get(async (req, res, next) => {\n        try {\n            const keys = await geminiKeyService.getAllGeminiKeysWithUsage();\n            res.json(keys);\n        } catch (error) {\n            next(error);\n        }\n    })\n    .post(async (req, res, next) => {\n        try {\n            const { key, name } = parseBody(req);\n             if (!key || typeof key !== 'string') {\n                return res.status(400).json({ error: 'Request body must include a valid API key (string)' });\n            }\n            const result = await geminiKeyService.addGeminiKey(key, name);\n            res.status(201).json({ success: true, ...result });\n        } catch (error) {\n             if (error.message.includes('duplicate API key')) {\n                return res.status(409).json({ error: 'Cannot add duplicate API key' });\n            }\n            next(error);\n        }\n    });\n\n// --- Batch Add Gemini Keys --- (/api/admin/gemini-keys/batch)\nrouter.post('/gemini-keys/batch', async (req, res, next) => {\n    try {\n        const { keys } = parseBody(req);\n        if (!Array.isArray(keys) || keys.length === 0) {\n            return res.status(400).json({ error: 'Request body must include a valid array of API keys' });\n        }\n\n        // Validate that all items are strings\n        const invalidKeys = keys.filter(key => !key || typeof key !== 'string');\n        if (invalidKeys.length > 0) {\n            return res.status(400).json({ error: 'All API keys must be valid strings' });\n        }\n\n        const result = await geminiKeyService.addMultipleGeminiKeys(keys);\n        res.status(201).json({\n            success: true,\n            ...result\n        });\n    } catch (error) {\n        next(error);\n    }\n});\n\nrouter.delete('/gemini-keys/:id', async (req, res, next) => {\n    try {\n        const keyId = req.params.id;\n        if (!keyId) {\n             return res.status(400).json({ error: 'Missing key ID in path' });\n        }\n        await geminiKeyService.deleteGeminiKey(keyId);\n        res.json({ success: true, id: keyId });\n    } catch (error) {\n         if (error.message.includes('not found')) {\n            return res.status(404).json({ error: error.message });\n        }\n        next(error);\n    }\n});\n\n// Base Gemini API URL\nconst BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com';\n\n// Helper function to check if a 400 error should be marked for key error\nfunction shouldMark400Error(responseBody) {\n    try {\n        // Only mark 400 errors if the message indicates invalid API key\n        if (responseBody && responseBody.error) {\n            const errorMessage = responseBody.error.message;\n\n            // Check for the specific \"API key not valid\" error\n            if (errorMessage && errorMessage.includes('API key not valid. Please pass a valid API key.')) {\n                return true;\n            }\n        }\n        return false;\n    } catch (e) {\n        // If we can't parse the error, don't mark it\n        return false;\n    }\n}\n\n// --- Test Gemini Key --- (/api/admin/test-gemini-key)\nrouter.post('/test-gemini-key', async (req, res, next) => {\n     try {\n        const { keyId, modelId } = parseBody(req);\n        if (!keyId || !modelId) {\n             return res.status(400).json({ error: 'Request body must include keyId and modelId' });\n        }\n\n        // Fetch the actual key from the database\n        const keyInfo = await configService.getDb('SELECT api_key FROM gemini_keys WHERE id = ?', [keyId]);\n        if (!keyInfo || !keyInfo.api_key) {\n            return res.status(404).json({ error: `API Key with ID '${keyId}' not found or invalid.` });\n        }\n        const apiKey = keyInfo.api_key;\n\n        // Fetch model category for potential usage increment\n        const modelsConfig = await configService.getModelsConfig();\n        let modelCategory = modelsConfig[modelId]?.category;\n\n        // If model is not configured, infer category from model name\n        if (!modelCategory) {\n            if (modelId.includes('flash')) {\n                modelCategory = 'Flash';\n            } else if (modelId.includes('pro')) {\n                modelCategory = 'Pro';\n            } else {\n                // Default to Flash for unknown models (most common case)\n                modelCategory = 'Flash';\n            }\n            console.log(`Model ${modelId} not configured, inferred category: ${modelCategory}`);\n        }\n\n        const testGeminiRequestBody = { contents: [{ role: \"user\", parts: [{ text: \"Hi\" }] }] };\n        const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${modelId}:generateContent`;\n\n        let testResponseStatus = 500;\n        let testResponseBody = null;\n        let isSuccess = false;\n\n        try {\n            // Get proxy agent\n            const agent = proxyPool.getNextProxyAgent();\n            const fetchOptions = {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json',\n                    'x-goog-api-key': apiKey\n                },\n                body: JSON.stringify(testGeminiRequestBody)\n            };\n            if (agent) {\n                fetchOptions.agent = agent;\n                console.log(`Admin API (Test Key): Sending request via proxy ${agent.proxy.href}`);\n            } else {\n                 console.log(`Admin API (Test Key): Sending request directly.`);\n            }\n\n            const response = await fetch(geminiUrl, fetchOptions);\n            testResponseStatus = response.status;\n            testResponseBody = await response.json(); // Attempt to parse JSON\n            isSuccess = response.ok;\n\nif (isSuccess) {\n                 // Increment usage and sync to GitHub\n                 await geminiKeyService.incrementKeyUsage(keyId, modelId, modelCategory);\n\n                 // Clear error status if the key was previously marked with an error\n                 // This allows previously failed keys to be restored when they work again during batch testing\n                 try {\n                     const wasCleared = await geminiKeyService.clearKeyError(keyId);\n                     if (wasCleared) {\n                         console.log(`Restored key ${keyId} - cleared previous error status during testing.`);\n                     }\n                 } catch (clearError) {\n                     // Log but don't fail the test if clearing error status fails\n                     console.warn(`Failed to clear error status for key ${keyId}:`, clearError);\n                 }\n            } else {\n                 // Record 400/401/403 errors (invalid API key, unauthorized, forbidden)\n                 // But only mark 400 errors if they indicate invalid API key\n                 if (testResponseStatus === 401 || testResponseStatus === 403) {\n                     await geminiKeyService.recordKeyError(keyId, testResponseStatus);\n                 } else if (testResponseStatus === 400) {\n                     // Check if this is an invalid API key 400 error that should be marked\n                     if (shouldMark400Error(testResponseBody)) {\n                         await geminiKeyService.recordKeyError(keyId, testResponseStatus);\n                     } else {\n                         console.log(`Skipping error marking for key ${keyId} - 400 error not related to invalid API key.`);\n                     }\n                 }\n            }\n\n        } catch (fetchError) {\n             console.error(`Error testing Gemini API key ${keyId}:`, fetchError);\n             testResponseBody = { error: `Fetch error: ${fetchError.message}` };\n             isSuccess = false;\n             // Don't assume network error means key is bad, could be temporary\n        }\n\n        res.status(isSuccess ? 200 : testResponseStatus).json({\n            success: isSuccess,\n            status: testResponseStatus,\n            content: testResponseBody\n        });\n\n    } catch (error) {\n        // Errors from fetching keyInfo etc.\n        next(error);\n    }\n});\n\n// --- Get Available Gemini Models --- (/api/admin/gemini-models)\nrouter.get('/gemini-models', async (req, res, next) => {\n     try {\n         // Helper function to fetch models with a specific key\n         const fetchModelsWithKey = async (key) => {\n             const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models`;\n\n             // Get proxy agent\n             const agent = proxyPool.getNextProxyAgent();\n             const fetchOptions = {\n                 method: 'GET',\n                 headers: {\n                     'Content-Type': 'application/json',\n                     'x-goog-api-key': key.key\n                 }\n             };\n             if (agent) {\n                fetchOptions.agent = agent;\n                console.log(`Admin API (Get Models): Sending request via proxy ${agent.proxy.href}`);\n             } else {\n                 console.log(`Admin API (Get Models): Sending request directly.`);\n             }\n\n             const response = await fetch(geminiUrl, fetchOptions);\n             return { response, keyId: key.id };\n         };\n\n         // Try to get models with up to 3 different keys\n         let lastError = null;\n         for (let attempt = 1; attempt <= 3; attempt++) {\n             // Find *any* valid key to make the models list request, without updating the rotation index\n             // This prevents writing to the database and GitHub sync on page refreshes\n             const availableKey = await geminiKeyService.getNextAvailableGeminiKey(null, false);\n             if (!availableKey) {\n                 console.warn(`Attempt ${attempt}: No available Gemini key found to fetch models list.`);\n                 break;\n             }\n\n             console.log(`Attempt ${attempt}: Fetching models with key ${availableKey.id}`);\n\n             try {\n                 const { response, keyId } = await fetchModelsWithKey(availableKey);\n\n                 if (response.ok) {\n                     // Success! Process and return the models\n                     const data = await response.json();\n                     const processedModels = (data.models || [])\n                        .filter(model => model.name?.startsWith('models/')) // Ensure correct format\n                        .map((model) => ({\n                             id: model.name.substring(7), // Extract ID\n                             name: model.displayName || model.name.substring(7), // Prefer displayName\n                             description: model.description,\n                             // Add other potentially useful fields: supportedGenerationMethods, version, etc.\n                         }));\n\n                     console.log(`Successfully fetched ${processedModels.length} models with key ${keyId}`);\n                     return res.json(processedModels);\n                 } else {\n                     // Handle error response\n                     const errorBody = await response.text();\n                     console.error(`Attempt ${attempt}: Error fetching Gemini models list (key ${keyId}): ${response.status} ${response.statusText}`, errorBody);\n\n                     // Mark key as invalid if it's a persistent error (401/403/400)\n                     if (response.status === 401 || response.status === 403) {\n                         console.log(`Marking key ${keyId} as invalid due to ${response.status} error during model list fetch`);\n                         await geminiKeyService.recordKeyError(keyId, response.status);\n                     } else if (response.status === 400) {\n                         // Check if this is an invalid API key 400 error that should be marked\n                         try {\n                             const errorBodyJson = JSON.parse(errorBody);\n                             if (shouldMark400Error(errorBodyJson)) {\n                                 console.log(`Marking key ${keyId} as invalid due to 400 error during model list fetch`);\n                                 await geminiKeyService.recordKeyError(keyId, response.status);\n                             } else {\n                                 console.log(`Skipping error marking for key ${keyId} during model list fetch - 400 error not related to invalid API key.`);\n                             }\n                         } catch (parseError) {\n                             // If we can't parse the error body, don't mark it as error\n                             console.log(`Skipping error marking for key ${keyId} during model list fetch - unparseable 400 response`);\n                         }\n                     }\n\n                     lastError = { status: response.status, body: errorBody };\n                     // Continue to next attempt with a different key\n                 }\n             } catch (fetchError) {\n                 console.error(`Attempt ${attempt}: Network error fetching models with key ${availableKey.id}:`, fetchError);\n                 lastError = fetchError;\n                 // Continue to next attempt\n             }\n         }\n\n         // If we get here, all attempts failed\n         console.warn(\"All attempts to fetch Gemini models failed. Returning empty list.\");\n         return res.json([]); // Return empty list if all attempts fail\n\n     } catch (error) {\n         console.error('Error handling /api/admin/gemini-models:', error);\n         next(error);\n     }\n});\n\n\n// --- Error Key Management ---\nrouter.get('/error-keys', async (req, res, next) => {\n    try {\n        const errorKeys = await geminiKeyService.getErrorKeys();\n        res.json(errorKeys);\n    } catch (error) {\n        next(error);\n    }\n});\n\nrouter.post('/clear-key-error', async (req, res, next) => {\n    try {\n        const { keyId } = parseBody(req);\n         if (!keyId || typeof keyId !== 'string') {\n            return res.status(400).json({ error: 'Request body must include a valid keyId (string)' });\n        }\n        await geminiKeyService.clearKeyError(keyId);\n        res.json({ success: true, id: keyId });\n    } catch (error) {\n         if (error.message.includes('not found')) {\n            return res.status(404).json({ error: error.message });\n        }\n        next(error);\n    }\n});\n\nrouter.delete('/error-keys', async (req, res, next) => {\n    try {\n        const result = await geminiKeyService.deleteAllErrorKeys();\n        res.json({\n            success: true,\n            deletedCount: result.deletedCount,\n            deletedKeys: result.deletedKeys\n        });\n    } catch (error) {\n        next(error);\n    }\n});\n\nrouter.post('/clear-all-errors', async (req, res, next) => {\n    try {\n        const result = await geminiKeyService.clearAllErrorKeys();\n        res.json({\n            success: true,\n            clearedCount: result.clearedCount,\n            clearedKeys: result.clearedKeys\n        });\n    } catch (error) {\n        next(error);\n    }\n});\n\n\n// --- Worker Key Management --- (/api/admin/worker-keys)\nrouter.route('/worker-keys')\n    .get(async (req, res, next) => {\n        try {\n            const keys = await configService.getAllWorkerKeys();\n            res.json(keys);\n        } catch (error) {\n            next(error);\n        }\n    })\n    .post(async (req, res, next) => {\n        try {\n            const { key, description } = parseBody(req);\n            if (!key || typeof key !== 'string' || key.trim() === '') {\n                 return res.status(400).json({ error: 'Request body must include a valid non-empty string: key' });\n            }\n            await configService.addWorkerKey(key.trim(), description);\n            res.status(201).json({ success: true, key: key.trim() });\n        } catch (error) {\n            if (error.message.includes('already exists')) {\n                return res.status(409).json({ error: error.message });\n            }\n            next(error);\n        }\n    });\n\nrouter.delete('/worker-keys/:key', async (req, res, next) => { // Use key in path param\n     try {\n        const keyToDelete = decodeURIComponent(req.params.key); // Decode URL component\n         if (!keyToDelete) {\n             return res.status(400).json({ error: 'Missing worker key in path' });\n         }\n        await configService.deleteWorkerKey(keyToDelete);\n        res.json({ success: true, key: keyToDelete });\n    } catch (error) {\n         if (error.message.includes('not found')) {\n            return res.status(404).json({ error: error.message });\n        }\n        next(error);\n    }\n});\n\nrouter.post('/worker-keys/safety-settings', async (req, res, next) => { // Specific path for safety\n    try {\n        const { key, safetyEnabled } = parseBody(req);\n        if (!key || typeof key !== 'string' || typeof safetyEnabled !== 'boolean') {\n            return res.status(400).json({ error: 'Request body must include key (string) and safetyEnabled (boolean)' });\n        }\n        await configService.updateWorkerKeySafety(key, safetyEnabled);\n        res.json({ success: true, key: key, safetyEnabled: safetyEnabled });\n    } catch (error) {\n         if (error.message.includes('not found')) {\n            return res.status(404).json({ error: error.message });\n        }\n        next(error);\n    }\n});\n\n\n// --- Model Configuration Management --- (/api/admin/models)\nrouter.route('/models')\n    .get(async (req, res, next) => {\n        try {\n            const config = await configService.getModelsConfig();\n            // Convert to array format expected by UI\n            const modelList = Object.entries(config).map(([id, data]) => ({ id, ...data }));\n            res.json(modelList);\n        } catch (error) {\n            next(error);\n        }\n    })\n    .post(async (req, res, next) => { // Add or Update\n        try {\n             const { id, category, dailyQuota, individualQuota } = parseBody(req);\n             if (!id || !category || !['Pro', 'Flash', 'Custom'].includes(category)) {\n                 return res.status(400).json({ error: 'Request body must include valid id and category (Pro, Flash, or Custom)' });\n             }\n             // Basic validation for quotas (more in service layer)\n             const dailyQuotaNum = (dailyQuota === null || dailyQuota === undefined || dailyQuota === '') ? null : Number(dailyQuota);\n             const individualQuotaNum = (individualQuota === null || individualQuota === undefined || individualQuota === '') ? null : Number(individualQuota);\n\n             if ((dailyQuotaNum !== null && isNaN(dailyQuotaNum)) || (individualQuotaNum !== null && isNaN(individualQuotaNum))) {\n                 return res.status(400).json({ error: 'Quotas must be numbers or null/empty.' });\n             }\n\n             await configService.setModelConfig(id, category, dailyQuotaNum, individualQuotaNum);\n             res.status(200).json({ success: true, id, category, dailyQuota: dailyQuotaNum, individualQuota: individualQuotaNum }); // Use 200 for add/update simplicity\n        } catch (error) {\n             if (error.message.includes('must be a non-negative integer')) {\n                return res.status(400).json({ error: error.message });\n             }\n            next(error);\n        }\n    });\n\nrouter.delete('/models/:id', async (req, res, next) => { // Use ID in path\n    try {\n        const modelIdToDelete = decodeURIComponent(req.params.id);\n         if (!modelIdToDelete) {\n             return res.status(400).json({ error: 'Missing model ID in path' });\n         }\n        await configService.deleteModelConfig(modelIdToDelete);\n        res.json({ success: true, id: modelIdToDelete });\n    } catch (error) {\n        if (error.message.includes('not found')) {\n            return res.status(404).json({ error: error.message });\n        }\n        next(error);\n    }\n});\n\n\n// --- Category Quota Management --- (/api/admin/category-quotas)\nrouter.route('/category-quotas')\n    .get(async (req, res, next) => {\n        try {\n            const quotas = await configService.getCategoryQuotas();\n            res.json(quotas);\n        } catch (error) {\n            next(error);\n        }\n    })\n    .post(async (req, res, next) => {\n        try {\n            const { proQuota, flashQuota } = parseBody(req);\n            // Service layer handles detailed validation\n             await configService.setCategoryQuotas(proQuota, flashQuota);\n             res.json({ success: true, proQuota, flashQuota });\n        } catch (error) {\n             if (error.message.includes('must be non-negative numbers')) {\n                 return res.status(400).json({ error: error.message });\n             }\n            next(error);\n        }\n    });\n\n// --- Vertex Configuration Management --- (/api/admin/vertex-config)\nrouter.route('/vertex-config')\n    .get(async (req, res, next) => {\n        try {\n            const config = await configService.getSetting('vertex_config', null);\n            res.json(config);\n        } catch (error) {\n            next(error);\n        }\n    })\n    .post(async (req, res, next) => {\n        try {\n            const { expressApiKey, vertexJson } = parseBody(req);\n\n            // Validate that at least one authentication method is provided\n            if (!expressApiKey && !vertexJson) {\n                return res.status(400).json({ error: 'Either Express API Key or Vertex JSON must be provided' });\n            }\n\n            // Validate that only one authentication method is provided\n            if (expressApiKey && vertexJson) {\n                return res.status(400).json({ error: 'Only one authentication method can be configured at a time' });\n            }\n\n            let configData = {};\n\n            if (expressApiKey) {\n                // Validate Express API Key format (basic validation)\n                if (typeof expressApiKey !== 'string' || expressApiKey.trim().length === 0) {\n                    return res.status(400).json({ error: 'Express API Key must be a non-empty string' });\n                }\n                configData.expressApiKey = expressApiKey.trim();\n            }\n\n            if (vertexJson) {\n                // Validate JSON format\n                try {\n                    const jsonData = JSON.parse(vertexJson);\n\n                    // Basic validation of required fields\n                    const requiredKeys = [\"type\", \"project_id\", \"private_key_id\", \"private_key\", \"client_email\", \"client_id\"];\n                    const missingKeys = requiredKeys.filter(key => !(key in jsonData));\n\n                    if (missingKeys.length > 0) {\n                        return res.status(400).json({\n                            error: `Invalid Service Account JSON. Missing required keys: ${missingKeys.join(', ')}`\n                        });\n                    }\n\n                    if (jsonData.type !== \"service_account\") {\n                        return res.status(400).json({\n                            error: \"Invalid Service Account JSON. 'type' must be 'service_account'\"\n                        });\n                    }\n\n                    configData.vertexJson = vertexJson.trim();\n                } catch (e) {\n                    return res.status(400).json({ error: 'Invalid JSON format for Vertex configuration' });\n                }\n            }\n\n            // Save configuration to database\n            await configService.setSetting('vertex_config', configData);\n\n            // Reinitialize Vertex service with new configuration\n            await vertexProxyService.reinitializeWithDatabaseConfig();\n\n            res.json({ success: true, message: 'Vertex configuration saved successfully' });\n        } catch (error) {\n            next(error);\n        }\n    })\n    .delete(async (req, res, next) => {\n        try {\n            // Clear the configuration\n            await configService.setSetting('vertex_config', null);\n\n            // Reinitialize Vertex service to clear configuration\n            await vertexProxyService.reinitializeWithDatabaseConfig();\n\n            res.json({ success: true, message: 'Vertex configuration cleared successfully' });\n        } catch (error) {\n            next(error);\n        }\n    });\n\n// Test Vertex Configuration\nrouter.post('/vertex-config/test', async (req, res, next) => {\n    try {\n        // Get current configuration\n        const config = await configService.getSetting('vertex_config', null);\n\n        if (!config || (!config.expressApiKey && !config.vertexJson)) {\n            return res.status(400).json({ error: 'No Vertex configuration found. Please configure Vertex first.' });\n        }\n\n        // Test the configuration by checking if Vertex is enabled\n        const isEnabled = vertexProxyService.isVertexEnabled();\n        const supportedModels = vertexProxyService.getVertexSupportedModels();\n\n        if (!isEnabled || supportedModels.length === 0) {\n            return res.status(400).json({ error: 'Vertex configuration test failed. Service is not properly initialized.' });\n        }\n\n        res.json({\n            success: true,\n            message: 'Vertex configuration test successful',\n            supportedModels: supportedModels.length,\n            authMode: config.expressApiKey ? 'Express Mode' : 'Service Account'\n        });\n    } catch (error) {\n        next(error);\n    }\n});\n\n// --- System Settings Management --- (/api/admin/system-settings)\nrouter.route('/system-settings')\n    .get(async (req, res, next) => {\n        try {\n            // Get settings from database\n            const keepalive = await configService.getSetting('keepalive', '0');\n            const maxRetry = await configService.getSetting('max_retry', '3');\n            const webSearch = await configService.getSetting('web_search', '0');\n            const autoTest = await configService.getSetting('auto_test', '0');\n\n            // Ensure consistent data types\n            res.json({\n                keepalive: String(keepalive), // Ensure it's a string\n                maxRetry: parseInt(maxRetry) || 3,\n                webSearch: String(webSearch),\n                autoTest: String(autoTest)\n            });\n        } catch (error) {\n            next(error);\n        }\n    })\n    .post(async (req, res, next) => {\n        try {\n            const { keepalive, maxRetry, webSearch, autoTest } = parseBody(req);\n\n            // Validate inputs\n            if (keepalive !== '0' && keepalive !== '1') {\n                return res.status(400).json({ error: 'KEEPALIVE must be \"0\" or \"1\"' });\n            }\n\n            const maxRetryNum = parseInt(maxRetry);\n            if (isNaN(maxRetryNum) || maxRetryNum < 0 || maxRetryNum > 10) {\n                return res.status(400).json({ error: 'MAX_RETRY must be a number between 0 and 10' });\n            }\n\n            if (webSearch !== '0' && webSearch !== '1') {\n                return res.status(400).json({ error: 'WEB_SEARCH must be \"0\" or \"1\"' });\n            }\n\n            if (autoTest !== '0' && autoTest !== '1') {\n                return res.status(400).json({ error: 'AUTO_TEST must be \"0\" or \"1\"' });\n            }\n\n            // Save to database (skip sync for first three, sync on the last one)\n            await configService.setSetting('keepalive', keepalive, true); // Skip sync\n            await configService.setSetting('max_retry', maxRetryNum.toString(), true); // Skip sync\n            await configService.setSetting('web_search', webSearch, true); // Skip sync\n            await configService.setSetting('auto_test', autoTest); // Trigger sync on last setting\n\n            // Update scheduler service when auto_test setting changes\n            try {\n                const schedulerService = require('../services/schedulerService');\n                await schedulerService.updateBatchTestSchedule();\n                console.log('Scheduler updated after auto_test setting change');\n            } catch (schedulerError) {\n                console.error('Failed to update scheduler:', schedulerError);\n                // Don't fail the request if scheduler update fails\n            }\n\n            res.json({\n                success: true,\n                keepalive: keepalive,\n                maxRetry: maxRetryNum,\n                webSearch: webSearch,\n                autoTest: autoTest\n            });\n        } catch (error) {\n            next(error);\n        }\n    });\n\n// --- Batch Test Management --- (/api/admin/batch-test)\nrouter.post('/batch-test/run', async (req, res, next) => {\n    try {\n        console.log('Manual batch test triggered via API');\n        const result = await batchTestService.runBatchTest();\n\n        res.json({\n            success: true,\n            message: 'Batch test completed',\n            ...result\n        });\n    } catch (error) {\n        console.error('Error running manual batch test:', error);\n        next(error);\n    }\n});\n\nrouter.get('/batch-test/status', async (req, res, next) => {\n    try {\n        const schedulerService = require('../services/schedulerService');\n        const schedulerStatus = schedulerService.getStatus();\n        const autoTestEnabled = await configService.getSetting('auto_test', '0');\n\n        res.json({\n            success: true,\n            autoTestEnabled: autoTestEnabled === '1',\n            schedulerStatus: schedulerStatus\n        });\n    } catch (error) {\n        console.error('Error getting batch test status:', error);\n        next(error);\n    }\n});\n\n\nmodule.exports = router;\n"
  },
  {
    "path": "src/routes/apiV1.js",
    "content": "// src/routes/apiV1.js\n\nconst express = require('express');\nconst { Readable, Transform } = require('stream'); // For handling streams and transforming\nconst requireWorkerAuth = require('../middleware/workerAuth');\nconst geminiProxyService = require('../services/geminiProxyService');\nconst configService = require('../services/configService'); // For /v1/models\nconst transformUtils = require('../utils/transform');\n\n// Import vertexProxyService, which now includes manual loading logic\nconst vertexProxyService = require('../services/vertexProxyService');\n\nconst router = express.Router();\n\n// Apply worker authentication middleware to all /v1 routes\nrouter.use(requireWorkerAuth);\n\n// --- /v1/models ---\nrouter.get('/models', async (req, res, next) => {\n    try {\n        const modelsConfig = await configService.getModelsConfig();\n        let modelsData = Object.keys(modelsConfig).map(modelId => ({\n            id: modelId,\n            object: \"model\",\n            created: Math.floor(Date.now() / 1000), // Placeholder timestamp\n            owned_by: \"google\", // Assuming all configured models are Google's\n            // Add other relevant properties if available/needed\n        }));\n\n        // Check if web search is enabled\n        const webSearchEnabled = String(await configService.getSetting('web_search', '0')) === '1';\n\n        // Add search versions for gemini-2.0+ series models only if web search is enabled\n        let searchModels = [];\n        if (webSearchEnabled) {\n            searchModels = Object.keys(modelsConfig)\n                .filter(modelId =>\n                    // Match gemini-2.0, gemini-2.5, gemini-3.0, etc. series models\n                    /^gemini-[2-9]\\.\\d/.test(modelId) &&\n                    // Exclude models that are already search versions\n                    !modelId.endsWith('-search')\n                )\n                .map(modelId => ({\n                    id: `${modelId}-search`,\n                    object: \"model\",\n                    created: Math.floor(Date.now() / 1000),\n                    owned_by: \"google\",\n                }));\n        }\n        \n        // Add non-thinking versions for gemini-2.5-flash-preview models\n        const nonThinkingModels = Object.keys(modelsConfig)\n            .filter(modelId => \n                // Currently only gemini-2.5-flash-preview supports thinkingBudget\n                modelId.includes('gemini-2.5-flash-preview') && \n                // Exclude models that are already non-thinking versions\n                !modelId.endsWith(':non-thinking')\n            )\n            .map(modelId => ({\n                id: `${modelId}:non-thinking`,\n                object: \"model\",\n                created: Math.floor(Date.now() / 1000),\n                owned_by: \"google\",\n            }));\n        \n        // Merge regular, search and non-thinking model lists\n        modelsData = [...modelsData, ...searchModels, ...nonThinkingModels];\n\n        // If Vertex feature is enabled (via manual loading), add Vertex AI supported models\n        if (vertexProxyService.isVertexEnabled()) {\n            const vertexModels = vertexProxyService.getVertexSupportedModels().map(modelId => ({\n                id: modelId,  // Model ID including [v] prefix\n                object: \"model\",\n                created: Math.floor(Date.now() / 1000),\n                owned_by: \"google\",\n            }));\n            \n            // Add Vertex models to the list\n            modelsData = [...modelsData, ...vertexModels];\n        }\n\n        res.json({ object: \"list\", data: modelsData });\n    } catch (error) {\n        console.error(\"Error handling /v1/models:\", error);\n        next(error); // Pass to global error handler\n    }\n});\n\n// --- /v1/chat/completions ---\nrouter.post('/chat/completions', async (req, res, next) => {\n    const openAIRequestBody = req.body;\n    const workerApiKey = req.workerApiKey; // Attached by requireWorkerAuth middleware\n    const stream = openAIRequestBody?.stream ?? false;\n    const requestedModelId = openAIRequestBody?.model; // Keep track for transformations\n    \n    try {\n        // --- Model Validation Step ---\n        // Get all available models to validate against the request\n        const modelsConfig = await configService.getModelsConfig();\n        let enabledModels = Object.keys(modelsConfig);\n\n        // Add search versions if web search is enabled\n        const webSearchEnabled = String(await configService.getSetting('web_search', '0')) === '1';\n        if (webSearchEnabled) {\n            const searchModels = Object.keys(modelsConfig)\n                .filter(modelId => /^gemini-[2-9]\\.\\d/.test(modelId) && !modelId.endsWith('-search'))\n                .map(modelId => `${modelId}-search`);\n            enabledModels = [...enabledModels, ...searchModels];\n        }\n        \n        // Add non-thinking versions\n        const nonThinkingModels = Object.keys(modelsConfig)\n            .filter(modelId => modelId.includes('gemini-2.5-flash-preview') && !modelId.endsWith(':non-thinking'))\n            .map(modelId => `${modelId}:non-thinking`);\n        enabledModels = [...enabledModels, ...nonThinkingModels];\n\n        // Add Vertex models if the feature is enabled\n        if (vertexProxyService.isVertexEnabled()) {\n            const vertexModels = vertexProxyService.getVertexSupportedModels();\n            enabledModels = [...enabledModels, ...vertexModels];\n        }\n\n        // Validate that the requested model is in the enabled list\n        if (!requestedModelId || !enabledModels.includes(requestedModelId)) {\n            return res.status(400).json({\n                error: {\n                    message: `Model not found or not enabled: ${requestedModelId}. Please check the /v1/models endpoint for available models.`,\n                    type: 'invalid_request_error',\n                    param: 'model'\n                }\n            });\n        }\n        // --- End Model Validation ---\n        \n        // Check if this is a non-thinking model request\n        const isNonThinking = requestedModelId?.endsWith(':non-thinking');\n        // Remove the suffix for actual model lookup, but keep original for response\n        const actualModelId = isNonThinking ? requestedModelId.replace(':non-thinking', '') : requestedModelId;\n        \n        // Set thinkingBudget to 0 for non-thinking models\n        const thinkingBudget = isNonThinking ? 0 : undefined;\n        \n        // If model was modified, update the request body with the actual model ID\n        if (isNonThinking) {\n            openAIRequestBody.model = actualModelId;\n        }\n\n        let result;\n\n        // KEEPALIVE mode setup - prepare heartbeat callback if needed\n        let keepAliveCallback = null;\n        const keepAliveEnabled = String(await configService.getSetting('keepalive', '0')) === '1';\n        const isSafetyEnabled = await configService.getWorkerKeySafetySetting(workerApiKey);\n        const useKeepAlive = keepAliveEnabled && stream && !isSafetyEnabled;\n\n        // Debug logging for KEEPALIVE mode\n        console.log(`KEEPALIVE Debug - keepAliveEnabled: ${keepAliveEnabled}, stream: ${stream}, isSafetyEnabled: ${isSafetyEnabled}, useKeepAlive: ${useKeepAlive}`);\n\n        if (useKeepAlive) {\n            // Set up KEEPALIVE heartbeat management\n            const { Readable } = require('stream');\n            const keepAliveSseStream = new Readable({ read() {} });\n            let keepAliveTimerId = null;\n            let isConnectionClosed = false;\n\n            // Function to safely clean up resources\n            const cleanup = () => {\n                if (keepAliveTimerId) {\n                    clearInterval(keepAliveTimerId);\n                    keepAliveTimerId = null;\n                }\n                isConnectionClosed = true;\n            };\n\n            // Monitor client connection status\n            res.on('close', () => {\n                console.log('KEEPALIVE: Client connection closed');\n                cleanup();\n            });\n\n            res.on('error', (err) => {\n                console.error('KEEPALIVE: Client connection error:', err);\n                cleanup();\n            });\n\n            // Handle stream errors\n            keepAliveSseStream.on('error', (err) => {\n                console.error('KEEPALIVE: Stream error:', err);\n                cleanup();\n            });\n\n            keepAliveSseStream.on('end', () => {\n                console.log('KEEPALIVE: Stream ended');\n                cleanup();\n            });\n\n            keepAliveSseStream.on('finish', () => {\n                console.log('KEEPALIVE: Stream finished');\n                cleanup();\n            });\n\n            const sendKeepAliveSseChunk = () => {\n                // Check multiple connection states\n                if (isConnectionClosed || res.writableEnded || res.destroyed || !res.writable) {\n                    cleanup();\n                    return;\n                }\n\n                try {\n                    const keepAliveSseData = {\n                        id: \"keepalive\",\n                        object: \"chat.completion.chunk\",\n                        created: Math.floor(Date.now() / 1000),\n                        model: requestedModelId,\n                        choices: [{ index: 0, delta: {}, finish_reason: null }]\n                    };\n                    keepAliveSseStream.push(`data: ${JSON.stringify(keepAliveSseData)}\\n\\n`);\n                } catch (err) {\n                    console.error('KEEPALIVE: Error sending heartbeat:', err);\n                    cleanup();\n                }\n            };\n\n            // Set streaming headers for KEEPALIVE mode\n            res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');\n            res.setHeader('Cache-Control', 'no-cache');\n            res.setHeader('Connection', 'keep-alive');\n            res.setHeader('X-Proxied-By', 'gemini-proxy-panel-node');\n\n            // Pipe stream to response after setting up error handlers\n            const pipeStream = keepAliveSseStream.pipe(res);\n\n            // Handle pipe errors\n            pipeStream.on('error', (err) => {\n                console.error('KEEPALIVE: Pipe error:', err);\n                cleanup();\n            });\n\n            // Create callback object for geminiProxyService\n            keepAliveCallback = {\n                startHeartbeat: () => {\n                    console.log('KEEPALIVE: Starting heartbeat (3 second intervals)');\n                    keepAliveTimerId = setInterval(sendKeepAliveSseChunk, 3000); // 3 second intervals\n                    sendKeepAliveSseChunk(); // Send first one immediately\n                },\n                stopHeartbeat: () => {\n                    console.log('KEEPALIVE: Stopping heartbeat');\n                    cleanup();\n                },\n                sendFinalResponse: (responseData) => {\n                    try {\n                        // Double-check connection status\n                        if (res.writableEnded || res.destroyed || !res.writable) {\n                            console.warn(\"KEEPALIVE: Response stream ended before data could be sent.\");\n                            return;\n                        }\n\n                        const openAIResponse = JSON.parse(transformUtils.transformGeminiResponseToOpenAI(\n                            responseData,\n                            requestedModelId\n                        ));\n                        const content = openAIResponse.choices[0].message.content || \"\";\n                        const completeChunk = {\n                            id: `chatcmpl-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,\n                            object: \"chat.completion.chunk\",\n                            created: Math.floor(Date.now() / 1000),\n                            model: requestedModelId,\n                            choices: [{\n                                index: 0,\n                                delta: { role: \"assistant\", content: content },\n                                finish_reason: openAIResponse.choices[0].finish_reason || \"stop\"\n                            }]\n                        };\n\n                        keepAliveSseStream.push(`data: ${JSON.stringify(completeChunk)}\\n\\n`);\n                        keepAliveSseStream.push('data: [DONE]\\n\\n');\n                        keepAliveSseStream.push(null); // End the stream\n                    } catch (error) {\n                        console.error(\"Error processing KEEPALIVE final response:\", error);\n                        const errorPayload = {\n                            error: {\n                                message: error.message || 'Failed to process KEEPALIVE response',\n                                type: error.type || 'keepalive_proxy_error',\n                                code: error.code,\n                                status: error.status\n                            }\n                        };\n                        keepAliveSseStream.push(`data: ${JSON.stringify(errorPayload)}\\n\\n`);\n                        keepAliveSseStream.push('data: [DONE]\\n\\n');\n                        keepAliveSseStream.push(null);\n                    }\n                },\n                sendError: (errorData) => {\n                    try {\n                        // Double-check connection status\n                        if (res.writableEnded || res.destroyed || !res.writable) {\n                            console.warn(\"KEEPALIVE: Response stream ended before error could be sent.\");\n                            return;\n                        }\n\n                        const errorPayload = {\n                            error: {\n                                message: errorData.message || 'Upstream API error',\n                                type: errorData.type || 'upstream_error',\n                                code: errorData.code\n                            }\n                        };\n\n                        keepAliveSseStream.push(`data: ${JSON.stringify(errorPayload)}\\n\\n`);\n                        keepAliveSseStream.push('data: [DONE]\\n\\n');\n                        keepAliveSseStream.push(null); // End the stream\n                    } catch (error) {\n                        console.error(\"Error sending KEEPALIVE error response:\", error);\n                        // Try to end the stream gracefully\n                        try {\n                            keepAliveSseStream.push(null);\n                        } catch (e) {\n                            console.error(\"Failed to end stream after error:\", e);\n                        }\n                    }\n                }\n            };\n        }\n\n        // Check if it's a Vertex model (with [v] prefix) and confirm Vertex feature is enabled\n        if (requestedModelId && requestedModelId.startsWith('[v]') && vertexProxyService.isVertexEnabled()) {\n            // Use Vertex proxy service to handle the request\n            console.log(`Using Vertex AI to process model: ${requestedModelId}`);\n            result = await vertexProxyService.proxyVertexChatCompletions(\n                openAIRequestBody,\n                workerApiKey,\n                stream,\n                keepAliveCallback\n            );\n        } else {\n            // Use Gemini proxy service to handle the request with optional thinkingBudget\n            result = await geminiProxyService.proxyChatCompletions(\n                openAIRequestBody,\n                workerApiKey,\n                stream,\n                thinkingBudget,\n                keepAliveCallback\n            );\n        }\n\n        // Check if the service returned an error\n        if (result.error) {\n            // In KEEPALIVE mode, send error through the heartbeat stream\n            if (useKeepAlive && keepAliveCallback) {\n                console.log('KEEPALIVE: Sending error response through heartbeat stream');\n                try {\n                    // Stop heartbeat first\n                    keepAliveCallback.stopHeartbeat();\n\n                    // Send error through the stream\n                    const errorPayload = {\n                        error: {\n                            message: result.error.message || 'Upstream API error',\n                            type: result.error.type || 'upstream_error',\n                            code: result.error.code,\n                            status: result.status || 500\n                        }\n                    };\n\n                    // Use the existing stream to send error\n                    const { Readable } = require('stream');\n                    const errorStream = new Readable({ read() {} });\n                    errorStream.pipe(res);\n                    errorStream.push(`data: ${JSON.stringify(errorPayload)}\\n\\n`);\n                    errorStream.push('data: [DONE]\\n\\n');\n                    errorStream.push(null);\n                    return;\n                } catch (streamError) {\n                    console.error('KEEPALIVE: Failed to send error through stream:', streamError);\n                    // Fallback: if stream fails, we can't do much more since headers are already sent\n                    return;\n                }\n            } else {\n                // Normal mode: set headers and send JSON error\n                res.setHeader('Content-Type', 'application/json');\n                return res.status(result.status || 500).json({ error: result.error });\n            }\n        }\n\n        // Destructure the successful result\n        const { response: geminiResponse, selectedKeyId, modelCategory } = result;\n\n        // --- Handle Response ---\n\n        // Check if this is a KEEPALIVE special response first\n        if (result.isKeepAlive) {\n            console.log(`KEEPALIVE mode activated for model ${requestedModelId} - response will be handled asynchronously`);\n            // In the new KEEPALIVE mode, the response is handled completely asynchronously\n            // The heartbeat is already started and the response will be sent when ready\n            // We just return here as everything is handled in the background\n            return; // Exit early for KEEPALIVE mode\n        }\n\n        // Set common headers (only for non-KEEPALIVE mode)\n        res.setHeader('X-Proxied-By', 'gemini-proxy-panel-node');\n        res.setHeader('X-Selected-Key-ID', selectedKeyId); // Send back which key was used (optional)\n\n        if (stream) {\n            // --- Streaming Response ---\n            res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');\n            res.setHeader('Cache-Control', 'no-cache');\n            res.setHeader('Connection', 'keep-alive');\n            // Apply CORS headers if not already handled globally by middleware\n            // res.setHeader('Access-Control-Allow-Origin', '*'); // Example if needed\n\n\n            // Check in advance if it's keepalive mode, if so, no need to check the body stream\n            if (!result.isKeepAlive) {\n                if (!geminiResponse.body || typeof geminiResponse.body.pipe !== 'function') {\n                    console.error('Gemini response body is not a readable stream for streaming request.');\n                    // Send a valid SSE error event before closing\n                    const errorPayload = JSON.stringify({ error: { message: 'Upstream response body is not readable.', type: 'proxy_error' } });\n                    res.write(`data: ${errorPayload}\\n\\n`);\n                    res.write('data: [DONE]\\n\\n');\n                    return res.end();\n                }\n            }\n\n            const decoder = new TextDecoder();\n            let buffer = '';\n            let lineBuffer = '';\n            let jsonCollector = '';\n            let isCollectingJson = false;\n            let openBraces = 0;\n            let closeBraces = 0;\n\n            // Implement stream processing transformer for both Gemini and Vertex streams\n            const streamTransformer = new Transform({\n                transform(chunk, encoding, callback) {\n                    try {\n                        const chunkStr = decoder.decode(chunk, { stream: true });\n                        buffer += chunkStr;\n\n                        // Process based on the source (Gemini or Vertex)\n                        if (selectedKeyId === 'vertex-ai') {\n                            // Vertex stream response is a series of continuous JSON objects without newline separation\n                            // Use a method similar to Gemini to process JSON objects\n                            let startPos = -1;\n                            let endPos = -1;\n                            let bracketDepth = 0;\n                            let inString = false;\n                            let escapeNext = false;\n                            let flushed = false;\n                            \n                            // Scan the entire buffer to find complete JSON objects\n                            for (let i = 0; i < buffer.length; i++) {\n                                const char = buffer[i];\n                                \n                                // Handle characters inside strings\n                                if (inString) {\n                                    if (escapeNext) {\n                                        escapeNext = false;\n                                    } else if (char === '\\\\') {\n                                        escapeNext = true;\n                                    } else if (char === '\"') {\n                                        inString = false;\n                                    }\n                                    continue;\n                                }\n                                \n                                // Handle characters outside strings\n                                if (char === '{') {\n                                    if (bracketDepth === 0) {\n                                        startPos = i; // Record the starting position of a new JSON object\n                                    }\n                                    bracketDepth++;\n                                } else if (char === '}') {\n                                    bracketDepth--;\n                                    if (bracketDepth === 0 && startPos !== -1) {\n                                        endPos = i;\n                                        \n                                        // Extract and process the complete JSON object\n                                        const jsonStr = buffer.substring(startPos, endPos + 1);\n                                        try {\n                                            // Check if it's the 'done' marker from vertexProxyService's flush\n                                            // We only need to parse if we suspect it might be the done object.\n                                            // Otherwise, jsonStr is already the stringified chunk we want.\n                                            if (jsonStr.includes('\"done\":true')) { // Quick check\n                                                try {\n                                                    const jsonObj = JSON.parse(jsonStr);\n                                                    if (jsonObj.done) {\n                                                        // This is the '{\"done\":true}' from vertexProxyService's flush.\n                                                        // The main flush of apiV1's transformer will send 'data: [DONE]\\n\\n'. So, ignore this one.\n                                                    } else {\n                                                        // It wasn't the done object, but was parsable. Send it.\n                                                        this.push(`data: ${jsonStr}\\n\\n`);\n                                                        if (typeof res.flush === 'function') res.flush();\n                                                    }\n                                                } catch (e) {\n                                                    // Parsing failed, but it might still be a valid (non-done) chunk.\n                                                    // This case should ideally not happen if vertexProxyService sends valid JSONs.\n                                                    console.error(\"Error parsing potential Vertex JSON object:\", e, \"Original string:\", jsonStr);\n                                                    this.push(`data: ${jsonStr}\\n\\n`); // Send as is if parsing fails but wasn't 'done'\n                                                    if (typeof res.flush === 'function') res.flush();\n                                                }\n                                            } else {\n                                                // Not the 'done' marker, so jsonStr is a data chunk.\n                                                this.push(`data: ${jsonStr}\\n\\n`);\n                                                if (typeof res.flush === 'function') res.flush();\n                                            }\n                                        } catch (e) {\n                                            // This outer catch handles errors from buffer.substring or other unexpected issues\n                                            console.error(\"Error processing Vertex JSON chunk:\", e, \"Original string:\", jsonStr);\n                                        }\n                                        \n                                        // Continue searching for the next object\n                                        startPos = -1;\n                                        \n                                        // Truncate the processed part\n                                        if (i + 1 < buffer.length) {\n                                            buffer = buffer.substring(endPos + 1);\n                                            i = -1; // Reset index to scan the remaining buffer from the beginning\n                                        } else {\n                                            buffer = '';\n                                            break; // Exit loop if buffer is exhausted\n                                        }\n                                    }\n                                } else if (char === '\"') {\n                                    inString = true;\n                                }\n                            }\n                        } else {\n                             // Original Gemini stream processing (find raw Gemini JSON chunks)\n                            let startPos = -1;\n                            let endPos = -1;\n                        let bracketDepth = 0;\n                        let inString = false;\n                        let escapeNext = false;\n                        \n                        // Scan the entire buffer to find complete JSON objects\n                        for (let i = 0; i < buffer.length; i++) {\n                            const char = buffer[i];\n                            \n                            // Handle characters within strings\n                            if (inString) {\n                                if (escapeNext) {\n                                    escapeNext = false;\n                                } else if (char === '\\\\') {\n                                    escapeNext = true;\n                                } else if (char === '\"') {\n                                    inString = false;\n                                }\n                                continue;\n                            }\n                            \n                            // Handle characters outside strings\n                            if (char === '{') {\n                                if (bracketDepth === 0) {\n                                    startPos = i; // Record the starting position of a new JSON object\n                                }\n                                bracketDepth++;\n                            } else if (char === '}') {\n                                bracketDepth--;\n                                if (bracketDepth === 0 && startPos !== -1) {\n                                    endPos = i;\n                                    \n                                    // Extract and process the complete JSON object\n                                    const jsonStr = buffer.substring(startPos, endPos + 1);\n                                    try {\n                                        const jsonObj = JSON.parse(jsonStr);\n                                        // Immediately process and send this object\n                                        processGeminiObject(jsonObj, this);\n                                    } catch (e) {\n                                        console.error(\"Error parsing JSON object:\", e);\n                                    }\n                                    \n                                                // Continue searching for the next object\n                                                startPos = -1;\n                                            }\n                                        } else if (char === '\"') {\n                                            inString = true;\n                                        } else if (char === '[' && !inString && startPos === -1) {\n                                            // Ignore the start marker of JSON arrays, as we process each object individually\n                                            continue;\n                                        } else if (char === ']' && !inString && bracketDepth === 0) {\n                                            // Ignore the end marker of JSON arrays\n                                            continue;\n                                        } else if (char === ',') {\n                                            // If there's a comma after an object, continue processing the next object\n                                            continue;\n                                        }\n                                    }\n                                    \n                                    // Keep the unprocessed part for Gemini stream\n                                    if (startPos !== -1 && endPos !== -1 && endPos > startPos) {\n                                        buffer = buffer.substring(endPos + 1);\n                                    } else if (startPos !== -1) {\n                                        buffer = buffer.substring(startPos);\n                                    } else {\n                                        buffer = '';\n                                    }\n                            } // End of else (Gemini stream processing)\n                        \n                        callback();\n                    } catch (e) {\n                        console.error(\"Error in stream transform:\", e);\n                        callback(e);\n                    }\n                },\n                \n                flush(callback) {\n                    try {\n                // Handling the remaining buffer\n                if (buffer.trim()) {\n                     if (selectedKeyId === 'vertex-ai') {\n                        if (buffer.trim()) {\n                            let startPos = -1;\n                            let endPos = -1;\n                            let bracketDepth = 0;\n                            let inString = false;\n                            let escapeNext = false;\n                            \n                            for (let i = 0; i < buffer.length; i++) {\n                                const char = buffer[i];\n                                \n                                if (inString) {\n                                    if (escapeNext) {\n                                        escapeNext = false;\n                                    } else if (char === '\\\\') {\n                                        escapeNext = true;\n                                    } else if (char === '\"') {\n                                        inString = false;\n                                    }\n                                    continue;\n                                }\n                                \n                                if (char === '{') {\n                                    if (bracketDepth === 0) {\n                                        startPos = i;\n                                    }\n                                    bracketDepth++;\n                                } else if (char === '}') {\n                                    bracketDepth--;\n                                    if (bracketDepth === 0 && startPos !== -1) {\n                                        endPos = i;\n                                        \n                                        try {\n                                            const jsonStr = buffer.substring(startPos, endPos + 1);\n                                            const jsonObj = JSON.parse(jsonStr);\n                                            if (!jsonObj.done) { // Avoid duplicate DONE\n                                                this.push(`data: ${JSON.stringify(jsonObj)}\\n\\n`);\n                                            }\n                                        } catch (e) {\n                                            console.debug(\"Could not parse Vertex buffer JSON:\", e);\n                                        }\n                                        \n                                        // Update the buffer and reset the index\n                                        if (endPos + 1 < buffer.length) {\n                                            buffer = buffer.substring(endPos + 1);\n                                            i = -1; // Reset index\n                                        } else {\n                                            buffer = '';\n                                            break;\n                                        }\n                                    }\n                                } else if (char === '\"') {\n                                    inString = true;\n                                }\n                            }\n                        }\n                     } else {\n                                // Try parsing remaining Gemini JSON object\n                                try {\n                                    const jsonObj = JSON.parse(buffer);\n                                    processGeminiObject(jsonObj, this); // Use existing Gemini processing\n                                } catch (e) {\n                                    console.debug(\"Could not parse final Gemini buffer:\", buffer, e);\n                                }\n                             }\n                        }\n                        \n                        // Always send the final [DONE] event\n                                                // console.log(\"Stream transformer flushing, sending [DONE].\"); // Removed log\n                                                this.push('data: [DONE]\\n\\n');\n                                                callback();\n                                            } catch (e) {\n                                                console.error(\"Error in stream flush:\", e); // Keep error log in English\n                        callback(e);\n                    }\n                }\n            });\n            \n            // Process a single Gemini API response object and convert it to OpenAI format\n            function processGeminiObject(geminiObj, stream) {\n                if (!geminiObj) return;\n                \n                // If it's a valid Gemini response object (contains candidates)\n                if (geminiObj.candidates && geminiObj.candidates.length > 0) {\n                    // Convert and send directly\n                    const openaiChunkStr = transformUtils.transformGeminiStreamChunk(geminiObj, requestedModelId);\n                    if (openaiChunkStr) {\n                        stream.push(openaiChunkStr);\n                    }\n                } else if (Array.isArray(geminiObj)) {\n                    // If it's an array, process each element\n                    for (const item of geminiObj) {\n                        processGeminiObject(item, stream);\n                    }\n                } else if (geminiObj.text) {\n                    // Single text fragment, construct Gemini format\n                    const mockGeminiChunk = {\n                        candidates: [{\n                            content: {\n                                parts: [{ text: geminiObj.text }],\n                                role: \"model\"\n                            }\n                        }]\n                    };\n                    \n                    const openaiChunkStr = transformUtils.transformGeminiStreamChunk(mockGeminiChunk, requestedModelId);\n                    if (openaiChunkStr) {\n                        stream.push(openaiChunkStr);\n                    }\n                }\n                // May need to handle other response types...\n            }\n\n            // Standard (non-KEEPALIVE) Gemini and Vertex streams\n            if (!geminiResponse || !geminiResponse.body || typeof geminiResponse.body.pipe !== 'function') {\n                console.error('Upstream response body is not a readable stream for standard streaming request.');\n                const errorPayload = JSON.stringify({ error: { message: 'Upstream response body is not readable.', type: 'proxy_error' } });\n                res.write(`data: ${errorPayload}\\n\\n`); // Use res.write for SSE\n                res.write('data: [DONE]\\n\\n');\n                return res.end();\n            }\n\n            console.log(`Piping ${selectedKeyId === 'vertex-ai' ? 'Vertex' : 'Gemini'} stream through transformer.`);\n            geminiResponse.body.pipe(streamTransformer).pipe(res);\n\n            geminiResponse.body.on('error', (err) => {\n                console.error(`Error reading stream from upstream (${selectedKeyId}):`, err);\n                if (!res.headersSent) {\n                    // If headers not sent, we can still send a JSON error\n                    res.status(500).json({ error: { message: 'Error reading stream from upstream API.' } });\n                } else if (!res.writableEnded) {\n                    // If headers sent but stream not ended, try to send an SSE error then end\n                    const sseError = JSON.stringify({ error: { message: 'Upstream stream error', type: 'upstream_error'} });\n                    res.write(`data: ${sseError}\\n\\n`);\n                    res.write('data: [DONE]\\n\\n');\n                    res.end();\n                }\n                // If res.writableEnded is true, nothing more we can do.\n            });\n\n            streamTransformer.on('error', (err) => {\n                console.error('Error in stream transformer:', err);\n                if (!res.headersSent) {\n                    res.status(500).json({ error: { message: 'Error processing stream data.' } });\n                } else if (!res.writableEnded) {\n                    const sseError = JSON.stringify({ error: { message: 'Stream processing error', type: 'transform_error'} });\n                    res.write(`data: ${sseError}\\n\\n`);\n                    res.write('data: [DONE]\\n\\n');\n                    res.end();\n                }\n            });\n\n             console.log(`Streaming response initiated for key ${selectedKeyId}`);\n\n\n        } else {\n            // --- Non-Streaming Response ---\n            res.setHeader('Content-Type', 'application/json; charset=utf-8');\n\n            try {\n                if (selectedKeyId === 'vertex-ai') {\n                    // Vertex service already transformed the response to OpenAI format\n                    const openaiJson = await geminiResponse.json(); // Get the pre-transformed JSON\n                    res.status(geminiResponse.status || 200).json(openaiJson); // Send it directly\n                    console.log(`Non-stream Vertex request completed, status: ${geminiResponse.status || 200}`);\n                } else {\n                    // Original Gemini service response handling\n                    const geminiJson = await geminiResponse.json(); // Parse the raw upstream Gemini JSON\n                    const openaiJsonString = transformUtils.transformGeminiResponseToOpenAI(geminiJson, requestedModelId); // Transform it\n                    // Use Gemini's original status code if available and OK, otherwise default to 200\n                    res.status(geminiResponse.ok ? geminiResponse.status : 200).send(openaiJsonString);\n                    console.log(`Non-stream Gemini request completed for key ${selectedKeyId}, status: ${geminiResponse.status}`);\n                }\n            } catch (jsonError) {\n                 console.error(\"Error parsing Gemini non-stream JSON response:\", jsonError);\n                 // Check if response text might give clues\n                 try {\n                    const errorText = await geminiResponse.text(); // Need to re-read or clone earlier\n                    console.error(\"Gemini non-stream response text:\", errorText);\n                 } catch(e){}\n                 next(new Error(\"Failed to parse upstream API response.\")); // Pass to global error handler\n            }\n        }\n\n    } catch (error) {\n        console.error(\"Error in /v1/chat/completions handler:\", error);\n        next(error); // Pass error to the global Express error handler\n    }\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "src/routes/auth.js",
    "content": "const express = require('express');\nconst { generateSessionToken, setSessionCookie, clearSessionCookie } = require('../utils/session');\nconst { readRequestBody } = require('../utils/helpers'); // Although body-parser is used, keep for consistency\n\nconst router = express.Router();\n\nconst ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;\n\nif (!ADMIN_PASSWORD) {\n    console.error(\"FATAL: ADMIN_PASSWORD environment variable is not set. Admin login disabled.\");\n}\n\n// --- Login Route ---\n// Path: /api/login (mounted under /api in server.js)\nrouter.post('/login', async (req, res, next) => {\n    if (!ADMIN_PASSWORD) {\n        return res.status(500).json({ error: 'Server configuration error: Admin password not set.' });\n    }\n\n    try {\n        // express.json() middleware populates req.body\n        const body = req.body;\n        if (!body || typeof body.password !== 'string') {\n            return res.status(400).json({ error: 'Password is required.' });\n        }\n\n        if (body.password === ADMIN_PASSWORD) {\n            // Password matches, generate and set session token\n            const token = await generateSessionToken();\n            if (!token) {\n                // Error already logged in generateSessionToken\n                return res.status(500).json({ error: 'Failed to generate session token.' });\n            }\n\n            setSessionCookie(res, token); // Set the cookie on the response\n            console.log('Admin login successful.');\n            return res.status(200).json({ success: true });\n\n        } else {\n            // Invalid password\n            console.warn('Admin login failed: Invalid password.');\n            return res.status(401).json({ error: 'Invalid password.' });\n        }\n    } catch (error) {\n        console.error(\"Error during login:\", error);\n        // Pass error to the global error handler\n        next(error);\n    }\n});\n\n// --- Logout Route ---\n// Path: /api/logout (mounted under /api in server.js)\nrouter.post('/logout', (req, res) => {\n    try {\n        clearSessionCookie(res); // Clear the cookie\n        console.log('Admin logout successful.');\n        // Send a simple success response. Client should handle redirect.\n        res.status(200).json({ success: true });\n    } catch (error) {\n        console.error(\"Error during logout:\", error);\n        // Pass error to the global error handler\n        // Note: synchronous errors might not be caught by Express error handler unless passed explicitly\n        next(error); // Ensure error is passed if any occurs unexpectedly\n    }\n});\n\nmodule.exports = router;\n"
  },
  {
    "path": "src/services/batchTestService.js",
    "content": "const fetch = require('node-fetch');\nconst configService = require('./configService');\nconst geminiKeyService = require('./geminiKeyService');\nconst proxyPool = require('../utils/proxyPool');\n\n// Base Gemini API URL\nconst BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com';\n\n// Helper function to check if a 400 error should be marked for key error\nfunction shouldMark400Error(responseBody) {\n    try {\n        // Only mark 400 errors if the message indicates invalid API key\n        if (responseBody && responseBody.error) {\n            const errorMessage = responseBody.error.message;\n\n            // Check for the specific \"API key not valid\" error\n            if (errorMessage && errorMessage.includes('API key not valid. Please pass a valid API key.')) {\n                return true;\n            }\n        }\n        return false;\n    } catch (e) {\n        // If we can't parse the error, don't mark it\n        return false;\n    }\n}\n\n/**\n * Tests a single Gemini API key\n * @param {string} keyId - The key ID to test\n * @param {string} modelId - The model ID to test with\n * @returns {Promise<{keyId: string, success: boolean, status: number|string, error?: string}>}\n */\nasync function testSingleKey(keyId, modelId) {\n    try {\n        // Fetch the actual key from the database\n        const keyInfo = await configService.getDb('SELECT api_key FROM gemini_keys WHERE id = ?', [keyId]);\n        if (!keyInfo || !keyInfo.api_key) {\n            return {\n                keyId,\n                success: false,\n                status: 'not_found',\n                error: `API Key with ID '${keyId}' not found or invalid.`\n            };\n        }\n        const apiKey = keyInfo.api_key;\n\n        // Fetch model category for potential usage increment\n        const modelsConfig = await configService.getModelsConfig();\n        let modelCategory = modelsConfig[modelId]?.category;\n\n        // If model is not configured, infer category from model name\n        if (!modelCategory) {\n            if (modelId.includes('flash')) {\n                modelCategory = 'Flash';\n            } else if (modelId.includes('pro')) {\n                modelCategory = 'Pro';\n            } else {\n                // Default to Flash for unknown models (most common case)\n                modelCategory = 'Flash';\n            }\n        }\n\n        const testGeminiRequestBody = { contents: [{ role: \"user\", parts: [{ text: \"Hi\" }] }] };\n        const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${modelId}:generateContent`;\n\n        let testResponseStatus = 500;\n        let testResponseBody = null;\n        let isSuccess = false;\n\n        try {\n            // Get proxy agent\n            const agent = proxyPool.getNextProxyAgent();\n            const fetchOptions = {\n                method: 'POST',\n                headers: {\n                    'Content-Type': 'application/json',\n                    'x-goog-api-key': apiKey\n                },\n                body: JSON.stringify(testGeminiRequestBody)\n            };\n            if (agent) {\n                fetchOptions.agent = agent;\n                console.log(`Batch Test (Key ${keyId}): Sending request via proxy ${agent.proxy.href}`);\n            } else {\n                console.log(`Batch Test (Key ${keyId}): Sending request directly.`);\n            }\n\n            const response = await fetch(geminiUrl, fetchOptions);\n            testResponseStatus = response.status;\n            testResponseBody = await response.json(); // Attempt to parse JSON\n            isSuccess = response.ok;\n\n            if (isSuccess) {\n                // Increment usage and sync to GitHub\n                await geminiKeyService.incrementKeyUsage(keyId, modelId, modelCategory);\n\n                // Clear error status if the key was previously marked with an error\n                try {\n                    const wasCleared = await geminiKeyService.clearKeyError(keyId);\n                    if (wasCleared) {\n                        console.log(`Batch Test: Restored key ${keyId} - cleared previous error status.`);\n                    }\n                } catch (clearError) {\n                    // Log but don't fail the test if clearing error status fails\n                    console.warn(`Batch Test: Failed to clear error status for key ${keyId}:`, clearError);\n                }\n            } else {\n                // Record 400/401/403 errors (invalid API key, unauthorized, forbidden)\n                // But only mark 400 errors if they indicate invalid API key\n                if (testResponseStatus === 401 || testResponseStatus === 403) {\n                    await geminiKeyService.recordKeyError(keyId, testResponseStatus);\n                } else if (testResponseStatus === 400) {\n                    // Check if this is an invalid API key 400 error that should be marked\n                    if (shouldMark400Error(testResponseBody)) {\n                        await geminiKeyService.recordKeyError(keyId, testResponseStatus);\n                    } else {\n                        console.log(`Batch Test: Skipping error marking for key ${keyId} - 400 error not related to invalid API key.`);\n                    }\n                }\n            }\n\n        } catch (fetchError) {\n            console.error(`Batch Test: Error testing Gemini API key ${keyId}:`, fetchError);\n            testResponseBody = { error: `Fetch error: ${fetchError.message}` };\n            isSuccess = false;\n            testResponseStatus = 'network_error';\n            // Don't assume network error means key is bad, could be temporary\n        }\n\n        return {\n            keyId,\n            success: isSuccess,\n            status: testResponseStatus,\n            error: isSuccess ? null : (testResponseBody?.error?.message || testResponseBody?.error || 'Test failed')\n        };\n\n    } catch (error) {\n        console.error(`Batch Test: Error processing key ${keyId}:`, error);\n        return {\n            keyId,\n            success: false,\n            status: 'processing_error',\n            error: error.message || 'Processing error'\n        };\n    }\n}\n\n/**\n * Runs batch test on all Gemini keys\n * @returns {Promise<{totalKeys: number, successCount: number, failureCount: number, results: Array}>}\n */\nasync function runBatchTest() {\n    console.log('Starting automated batch test...');\n    \n    try {\n        // Get all Gemini keys\n        const keys = await geminiKeyService.getAllGeminiKeysWithUsage();\n        if (!keys || keys.length === 0) {\n            console.log('Batch Test: No Gemini keys found to test.');\n            return {\n                totalKeys: 0,\n                successCount: 0,\n                failureCount: 0,\n                results: []\n            };\n        }\n\n        const totalKeys = keys.length;\n        const testModel = 'gemini-2.0-flash'; // Fixed model for testing\n        const results = [];\n        let successCount = 0;\n        let failureCount = 0;\n\n        console.log(`Batch Test: Testing ${totalKeys} keys with model ${testModel}`);\n\n        // Process keys in batches to balance performance and server load\n        const batchSize = 5; // Optimal batch size for testing\n        for (let i = 0; i < keys.length; i += batchSize) {\n            const batch = keys.slice(i, i + batchSize);\n            \n            console.log(`Batch Test: Processing batch ${Math.floor(i / batchSize) + 1} (${batch.length} keys)`);\n\n            // Run tests for current batch concurrently\n            const batchPromises = batch.map(key => testSingleKey(key.id, testModel));\n            const batchResults = await Promise.allSettled(batchPromises);\n\n            // Process results\n            batchResults.forEach((result, index) => {\n                if (result.status === 'fulfilled') {\n                    const testResult = result.value;\n                    results.push(testResult);\n                    \n                    if (testResult.success) {\n                        successCount++;\n                        console.log(`Batch Test: Key ${testResult.keyId} - SUCCESS`);\n                    } else {\n                        failureCount++;\n                        console.log(`Batch Test: Key ${testResult.keyId} - FAILED (${testResult.status}): ${testResult.error}`);\n                    }\n                } else {\n                    const keyId = batch[index].id;\n                    failureCount++;\n                    results.push({\n                        keyId,\n                        success: false,\n                        status: 'promise_rejected',\n                        error: result.reason?.message || 'Promise rejected'\n                    });\n                    console.log(`Batch Test: Key ${keyId} - PROMISE REJECTED: ${result.reason?.message}`);\n                }\n            });\n\n            // Delay between batches to reduce server load\n            if (i + batchSize < keys.length) {\n                console.log('Batch Test: Waiting 1 second before next batch...');\n                await new Promise(resolve => setTimeout(resolve, 1000));\n            }\n        }\n\n        const summary = {\n            totalKeys,\n            successCount,\n            failureCount,\n            results\n        };\n\n        console.log(`Batch Test completed: ${successCount} successful, ${failureCount} failed out of ${totalKeys} total keys.`);\n        return summary;\n\n    } catch (error) {\n        console.error('Batch Test: Error during batch test execution:', error);\n        throw error;\n    }\n}\n\nmodule.exports = {\n    testSingleKey,\n    runBatchTest\n};\n"
  },
  {
    "path": "src/services/configService.js",
    "content": "const dbModule = require('../db');\n\n// --- Helper Functions for DB Interaction ---\n\n/**\n * Helper function to get database instance\n * @returns {object} Database instance\n */\nconst getDbInstance = () => {\n    const db = dbModule.db;\n    if (!db) {\n        throw new Error('Database not initialized');\n    }\n    return db;\n};\n\n/**\n * Helper function to run a single SQL query with parameters.\n * Returns a Promise.\n * @param {string} sql The SQL query string.\n * @param {Array} params Query parameters.\n * @returns {Promise<object>} Promise resolving with { lastID, changes } or rejecting with error.\n */\nconst runDb = (sql, params = []) => {\n    return new Promise((resolve, reject) => {\n        const db = getDbInstance();\n        db.run(sql, params, function (err) { // Use function() to access this context\n            if (err) {\n                console.error('Database run error:', err.message, 'SQL:', sql, 'Params:', params);\n                reject(err);\n            } else {\n                resolve({ lastID: this.lastID, changes: this.changes });\n            }\n        });\n    });\n};\n\n/**\n * Helper function to get a single row from the database.\n * Returns a Promise.\n * @param {string} sql The SQL query string.\n * @param {Array} params Query parameters.\n * @returns {Promise<object|null>} Promise resolving with the row or null, or rejecting with error.\n */\nconst getDb = (sql, params = []) => {\n    return new Promise((resolve, reject) => {\n        const db = getDbInstance();\n        db.get(sql, params, (err, row) => {\n            if (err) {\n                console.error('Database get error:', err.message, 'SQL:', sql, 'Params:', params);\n                reject(err);\n            } else {\n                resolve(row);\n            }\n        });\n    });\n};\n\n/**\n * Helper function to get all rows from the database.\n * Returns a Promise.\n * @param {string} sql The SQL query string.\n * @param {Array} params Query parameters.\n * @returns {Promise<Array>} Promise resolving with an array of rows or rejecting with error.\n */\nconst allDb = (sql, params = []) => {\n    return new Promise((resolve, reject) => {\n        const db = getDbInstance();\n        db.all(sql, params, (err, rows) => {\n            if (err) {\n                console.error('Database all error:', err.message, 'SQL:', sql, 'Params:', params);\n                reject(err);\n            } else {\n                resolve(rows);\n            }\n        });\n    });\n};\n\n// Simple queue for serializing database operations\nlet dbOperationQueue = Promise.resolve();\n\n/**\n * Executes a series of database operations sequentially.\n * @param {Function} callback A function that performs async operations.\n * @returns {Promise<any>} Returns the result of the callback function.\n */\nconst serializeDb = (callback) => {\n    // Chain the operation to the queue\n    dbOperationQueue = dbOperationQueue.then(async () => {\n        try {\n            return await callback();\n        } catch (error) {\n            // Log error but don't break the queue\n            console.error('Error in serialized database operation:', error);\n            throw error;\n        }\n    });\n\n    return dbOperationQueue;\n};\n\n// --- Settings Management (Generic Key-Value) ---\n\n/**\n * Gets a specific setting value from the 'settings' table.\n * @param {string} key The setting key.\n * @param {any} [defaultValue=null] Value to return if key not found.\n * @returns {Promise<any>} The setting value (parsed if JSON) or defaultValue.\n */\nasync function getSetting(key, defaultValue = null) {\n    const row = await getDb('SELECT value FROM settings WHERE key = ?', [key]);\n    if (!row) {\n        return defaultValue;\n    }\n    try {\n        // Attempt to parse as JSON, fallback to raw value\n        return JSON.parse(row.value);\n    } catch (e) {\n        return row.value; // Return as string if not valid JSON\n    }\n}\n\n/**\n * Sets a specific setting value in the 'settings' table.\n * Automatically stringifies objects/arrays.\n * @param {string} key The setting key.\n * @param {any} value The value to set.\n * @param {boolean} [skipSync=false] Skip sync to GitHub if true.\n * @param {boolean} [useTransaction=false] Whether this call is part of an existing transaction.\n * @returns {Promise<void>}\n */\nasync function setSetting(key, value, skipSync = false, useTransaction = false) {\n    // Convert value to string for storage\n    const valueToStore = (typeof value === 'object' && value !== null)\n        ? JSON.stringify(value)\n        : String(value); // Ensure it's a string if not object/array\n    \n    // If not part of an existing transaction, start a new one\n    if (!useTransaction) {\n        await runDb('BEGIN TRANSACTION');\n    }\n    \n    try {\n        // Update or insert the setting\n        await runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, valueToStore]);\n        \n        // If we started a transaction, commit it\n        if (!useTransaction) {\n            await runDb('COMMIT');\n            \n            // Sync updates to GitHub (unless skipped)\n            if (!skipSync) {\n                await dbModule.syncToGitHub();\n            }\n        }\n    } catch (error) {\n        // If we started a transaction and an error occurred, roll it back\n        if (!useTransaction) {\n            await runDb('ROLLBACK');\n        }\n        // Re-throw the error to be handled by the caller\n        throw error;\n    }\n}\n\n\n// --- Model Configuration ---\n\n/**\n * Gets the entire models configuration object.\n * @returns {Promise<Record<string, {category: string, dailyQuota?: number, individualQuota?: number}>>}\n */\nasync function getModelsConfig() {\n    const rows = await allDb('SELECT * FROM models_config');\n    const config = {};\n    rows.forEach(row => {\n        config[row.model_id] = {\n            category: row.category,\n            // Return null or undefined from DB as undefined\n            dailyQuota: row.daily_quota ?? undefined,\n            individualQuota: row.individual_quota ?? undefined\n        };\n    });\n    return config;\n}\n\n/**\n * Adds or updates a model configuration.\n * @param {string} modelId\n * @param {'Pro' | 'Flash' | 'Custom'} category\n * @param {number | null | undefined} dailyQuota Use null/undefined for no limit.\n * @param {number | null | undefined} individualQuota Use null/undefined for no limit.\n * @returns {Promise<void>}\n */\nasync function setModelConfig(modelId, category, dailyQuota, individualQuota) {\n    // Ensure null is stored in DB if quota is undefined or explicitly null\n    const dailyQuotaDb = (dailyQuota === undefined || dailyQuota === null) ? null : Number(dailyQuota);\n    const individualQuotaDb = (individualQuota === undefined || individualQuota === null) ? null : Number(individualQuota);\n\n    if ((category === 'Custom' && dailyQuotaDb !== null && !Number.isInteger(dailyQuotaDb)) || dailyQuotaDb < 0) {\n        throw new Error(\"Custom model dailyQuota must be a non-negative integer or null.\");\n    }\n    if (((category === 'Pro' || category === 'Flash') && individualQuotaDb !== null && !Number.isInteger(individualQuotaDb)) || individualQuotaDb < 0) {\n        throw new Error(\"Pro/Flash model individualQuota must be a non-negative integer or null.\");\n    }\n\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await serializeDb(async () => {\n        await runDb('BEGIN TRANSACTION');\n\n        try {\n            const sql = `\n                INSERT OR REPLACE INTO models_config\n                (model_id, category, daily_quota, individual_quota)\n                VALUES (?, ?, ?, ?)\n            `;\n\n            await runDb(sql, [modelId, category, dailyQuotaDb, individualQuotaDb]);\n\n            // Commit the transaction\n            await runDb('COMMIT');\n\n            // Sync updates to GitHub (outside transaction)\n            await dbModule.syncToGitHub();\n        } catch (error) {\n            // Rollback on error\n            await runDb('ROLLBACK');\n            throw error;\n        }\n    });\n}\n\n/**\n * Deletes a model configuration.\n * @param {string} modelId\n * @returns {Promise<void>}\n */\nasync function deleteModelConfig(modelId) {\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await serializeDb(async () => {\n        await runDb('BEGIN TRANSACTION');\n\n        try {\n            const result = await runDb('DELETE FROM models_config WHERE model_id = ?', [modelId]);\n\n            if (result.changes === 0) {\n                await runDb('ROLLBACK');\n                throw new Error(`Model '${modelId}' not found for deletion.`);\n            }\n\n            await runDb('COMMIT');\n\n            // Sync updates to GitHub (outside transaction)\n            await dbModule.syncToGitHub();\n        } catch (error) {\n            await runDb('ROLLBACK');\n            throw error;\n        }\n    });\n}\n\n\n// --- Category Quotas ---\n\n/**\n * Gets the category quotas (Pro/Flash).\n * @returns {Promise<{proQuota: number, flashQuota: number}>}\n */\nasync function getCategoryQuotas() {\n    // Retrieve from settings table, providing defaults\n    const quotas = await getSetting('category_quotas', { proQuota: 50, flashQuota: 1500 });\n    // Ensure the retrieved value has the expected format\n     return {\n        proQuota: typeof quotas?.proQuota === 'number' ? quotas.proQuota : 50,\n        flashQuota: typeof quotas?.flashQuota === 'number' ? quotas.flashQuota : 1500,\n    };\n}\n\n/**\n * Sets the category quotas.\n * @param {number} proQuota\n * @param {number} flashQuota\n * @returns {Promise<void>}\n */\nasync function setCategoryQuotas(proQuota, flashQuota) {\n    if (typeof proQuota !== 'number' || typeof flashQuota !== 'number' || proQuota < 0 || flashQuota < 0) {\n        throw new Error(\"Quotas must be non-negative numbers.\");\n    }\n\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await serializeDb(async () => {\n        await runDb('BEGIN TRANSACTION');\n\n        try {\n            // Save directly with SQL to avoid nested transactions\n            const quotasObj = {\n                proQuota: Math.floor(proQuota),\n                flashQuota: Math.floor(flashQuota)\n            };\n\n            await runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',\n                ['category_quotas', JSON.stringify(quotasObj)]);\n\n            // Commit the transaction\n            await runDb('COMMIT');\n\n            // Sync updates to GitHub outside transaction\n            await dbModule.syncToGitHub();\n        } catch (error) {\n            // Rollback on error\n            await runDb('ROLLBACK');\n            throw error;\n        }\n    });\n}\n\n\n// --- Worker Keys ---\n\n/**\n * Gets all worker keys with their descriptions and safety settings.\n * @returns {Promise<Array<{key: string, description: string, safetyEnabled: boolean, createdAt: string}>>}\n */\nasync function getAllWorkerKeys() {\n    const rows = await allDb('SELECT api_key, description, safety_enabled, created_at FROM worker_keys ORDER BY created_at DESC');\n    return rows.map(row => ({\n        key: row.api_key,\n        description: row.description || '',\n        safetyEnabled: row.safety_enabled === 1, // Convert DB integer to boolean\n        createdAt: row.created_at\n    }));\n}\n\n/**\n * Gets safety setting for a specific worker key.\n * @param {string} apiKey The worker API key.\n * @returns {Promise<boolean>} True if safety is enabled, false otherwise (defaults to true if key not found, though middleware should prevent this).\n */\nasync function getWorkerKeySafetySetting(apiKey) {\n     const row = await getDb('SELECT safety_enabled FROM worker_keys WHERE api_key = ?', [apiKey]);\n     // Default to true if key doesn't exist (shouldn't happen if middleware is used) or if value is null/undefined\n     return row ? row.safety_enabled === 1 : true;\n}\n\n\n/**\n * Adds a new worker key.\n * @param {string} apiKey\n * @param {string} [description='']\n * @returns {Promise<void>}\n */\nasync function addWorkerKey(apiKey, description = '') {\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await serializeDb(async () => {\n        await runDb('BEGIN TRANSACTION');\n\n        try {\n            const sql = `\n                INSERT INTO worker_keys (api_key, description, safety_enabled, created_at)\n                VALUES (?, ?, ?, CURRENT_TIMESTAMP)\n            `;\n\n            await runDb(sql, [apiKey, description, 1]); // Default safety_enabled to true (1)\n\n            // Commit transaction\n            await runDb('COMMIT');\n\n            // Sync updates to GitHub (outside transaction)\n            await dbModule.syncToGitHub();\n        } catch (err) {\n            // Rollback on error\n            await runDb('ROLLBACK');\n\n            if (err.code === 'SQLITE_CONSTRAINT') { // Handle potential unique constraint violation\n                throw new Error(`Worker key '${apiKey}' already exists.`);\n            }\n            throw err; // Re-throw other errors\n        }\n    });\n}\n\n/**\n * Updates a worker key's safety setting.\n * @param {string} apiKey\n * @param {boolean} safetyEnabled\n * @returns {Promise<void>}\n */\nasync function updateWorkerKeySafety(apiKey, safetyEnabled) {\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await serializeDb(async () => {\n        await runDb('BEGIN TRANSACTION');\n\n        try {\n            const sql = `UPDATE worker_keys SET safety_enabled = ? WHERE api_key = ?`;\n            const result = await runDb(sql, [safetyEnabled ? 1 : 0, apiKey]);\n\n            if (result.changes === 0) {\n                await runDb('ROLLBACK');\n                throw new Error(`Worker key '${apiKey}' not found for updating safety settings.`);\n            }\n\n            await runDb('COMMIT');\n\n            // Sync updates to GitHub (outside transaction)\n            await dbModule.syncToGitHub();\n        } catch (error) {\n            await runDb('ROLLBACK');\n            throw error;\n        }\n    });\n}\n\n\n/**\n * Deletes a worker key.\n * @param {string} apiKey\n * @returns {Promise<void>}\n */\nasync function deleteWorkerKey(apiKey) {\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await serializeDb(async () => {\n        await runDb('BEGIN TRANSACTION');\n\n        try {\n            const result = await runDb('DELETE FROM worker_keys WHERE api_key = ?', [apiKey]);\n\n            if (result.changes === 0) {\n                await runDb('ROLLBACK');\n                throw new Error(`Worker key '${apiKey}' not found for deletion.`);\n            }\n\n            await runDb('COMMIT');\n\n            // Sync updates to GitHub (outside transaction)\n            await dbModule.syncToGitHub();\n        } catch (error) {\n            await runDb('ROLLBACK');\n            throw error;\n        }\n    });\n}\n\n\n// --- GitHub Configuration ---\n\n/**\n * Gets the GitHub repository configuration.\n * @returns {Promise<{repo: string, token: string, dbPath: string, encryptKey: string|null}>}\n */\nasync function getGitHubConfig() {\n    return await getSetting('github_config', { repo: '', token: '', dbPath: './database.db', encryptKey: null });\n}\n\n/**\n * Sets the GitHub repository configuration.\n * @param {string} repo The GitHub repository in format \"username/repo-name\"\n * @param {string} token GitHub personal access token\n * @param {string} [dbPath='./database.db'] Path to the database file\n * @param {string|null} [encryptKey=null] Optional encryption key for database file\n * @returns {Promise<void>}\n */\nasync function setGitHubConfig(repo, token, dbPath = './database.db', encryptKey = null) {\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await serializeDb(async () => {\n        await runDb('BEGIN TRANSACTION');\n\n        try {\n            // Save directly with SQL to avoid nested transactions\n            const configObj = { repo, token, dbPath, encryptKey };\n\n            await runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',\n                ['github_config', JSON.stringify(configObj)]);\n\n            // Commit the transaction\n            await runDb('COMMIT');\n\n            // Sync updates to GitHub outside transaction\n            await dbModule.syncToGitHub();\n        } catch (error) {\n            // Rollback on error\n            await runDb('ROLLBACK');\n            throw error;\n        }\n    });\n}\n\n\nmodule.exports = {\n    // Settings\n    getSetting,\n    setSetting,\n    // GitHub\n    getGitHubConfig,\n    setGitHubConfig,\n    // Models\n    getModelsConfig,\n    setModelConfig,\n    deleteModelConfig,\n    // Category Quotas\n    getCategoryQuotas,\n    setCategoryQuotas,\n    // Worker Keys\n    getAllWorkerKeys,\n    getWorkerKeySafetySetting,\n    addWorkerKey,\n    updateWorkerKeySafety,\n    deleteWorkerKey,\n    // DB helpers (optional export if needed elsewhere)\n    runDb,\n    getDb,\n    allDb,\n    serializeDb,\n};\n"
  },
  {
    "path": "src/services/geminiKeyService.js",
    "content": "const dbModule = require('../db');\nconst configService = require('./configService'); // Use configService for DB helpers and settings\nconst { getTodayInLA } = require('../utils/helpers');\nconst crypto = require('crypto'); // For generating key IDs\n\n// --- Gemini Key CRUD Operations ---\n\n/**\n * Adds a new Gemini API key to the database.\n * @param {string} apiKey The actual Gemini API key.\n * @param {string} [name] Optional name for the key.\n * @returns {Promise<{id: string, name: string}>} The ID and name of the added key.\n */\nasync function addGeminiKey(apiKey, name) {\n    if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') {\n        throw new Error('Invalid API key provided.');\n    }\n    const trimmedApiKey = apiKey.trim();\n\n    // Generate a unique ID\n    const timestamp = Date.now();\n    const randomString = crypto.randomBytes(4).toString('hex'); // Use crypto for better randomness\n    const keyId = `gk-${timestamp}-${randomString}`;\n    const keyName = (typeof name === 'string' && name.trim()) ? name.trim() : keyId;\n\n    const insertSQL = `\n        INSERT INTO gemini_keys\n        (id, api_key, name, usage_date, model_usage, category_usage, error_status, consecutive_429_counts, created_at)\n        VALUES (?, ?, ?, '', '{}', '{}', NULL, '{}', CURRENT_TIMESTAMP)\n    `;\n\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    return await configService.serializeDb(async () => {\n        // Start a single transaction for the entire operation\n        await configService.runDb('BEGIN TRANSACTION');\n\n        try {\n            // Insert the key first\n            await configService.runDb(insertSQL, [keyId, trimmedApiKey, keyName]);\n\n            // Get the current list directly with SQL\n            const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);\n            let currentList = [];\n\n            try {\n                currentList = currentListValue ? JSON.parse(currentListValue.value) : [];\n                if (!Array.isArray(currentList)) {\n                    console.warn(\"Setting 'gemini_key_list' is not an array, resetting.\");\n                    currentList = [];\n                }\n            } catch (e) {\n                console.warn(\"Error parsing gemini_key_list, resetting:\", e);\n                currentList = [];\n            }\n\n            // Add the new key ID to the list\n            currentList.push(keyId);\n\n            // Update the list directly with SQL\n            await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',\n                ['gemini_key_list', JSON.stringify(currentList)]);\n\n            // Commit the transaction\n            await configService.runDb('COMMIT');\n\n            console.log(`Added key ${keyId} to database and rotation list.`);\n\n            return { id: keyId, name: keyName };\n        } catch (error) {\n            // Rollback transaction on error\n            await configService.runDb('ROLLBACK');\n            console.error(`Transaction error while adding gemini key:`, error);\n            throw error;\n        }\n    }).then(result => {\n        // Sync updates to GitHub outside of serialized operation\n        dbModule.syncToGitHub().catch(err => {\n            console.warn(`Failed to sync to GitHub after adding key ${keyId}:`, err);\n        });\n        return result;\n    }).catch(err => {\n        if (err.message.includes('UNIQUE constraint failed: gemini_keys.api_key')) {\n            throw new Error('Cannot add duplicate API key.');\n        }\n        console.error(`Error adding Gemini key:`, err);\n        throw new Error(`Failed to add Gemini key: ${err.message}`);\n    });\n}\n\n/**\n * Adds multiple Gemini API keys in a single transaction for better performance.\n * @param {Array<string>} apiKeys Array of API keys to add.\n * @returns {Promise<{successCount: number, failureCount: number, results: Array<{key: string, success: boolean, id?: string, name?: string, error?: string}>}>}\n */\nasync function addMultipleGeminiKeys(apiKeys) {\n    if (!Array.isArray(apiKeys) || apiKeys.length === 0) {\n        throw new Error('Invalid API keys array provided.');\n    }\n\n    const results = [];\n    let successCount = 0;\n    let failureCount = 0;\n\n    // Use serializeDb to ensure atomic operations\n    return await configService.serializeDb(async () => {\n        await configService.runDb('BEGIN TRANSACTION');\n\n        try {\n            // Get the current list\n            const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);\n            let currentList = [];\n\n            try {\n                currentList = currentListValue ? JSON.parse(currentListValue.value) : [];\n                if (!Array.isArray(currentList)) {\n                    console.warn(\"Setting 'gemini_key_list' is not an array, resetting.\");\n                    currentList = [];\n                }\n            } catch (e) {\n                console.warn(\"Error parsing gemini_key_list, resetting:\", e);\n                currentList = [];\n            }\n\n            const newKeyIds = [];\n\n            // Process each key\n            for (const apiKey of apiKeys) {\n                try {\n                    if (!apiKey || typeof apiKey !== 'string' || apiKey.trim() === '') {\n                        results.push({\n                            key: apiKey,\n                            success: false,\n                            error: 'Invalid API key provided.'\n                        });\n                        failureCount++;\n                        continue;\n                    }\n\n                    const trimmedApiKey = apiKey.trim();\n\n                    // Generate a unique ID\n                    const timestamp = Date.now();\n                    const randomString = crypto.randomBytes(4).toString('hex');\n                    const keyId = `gk-${timestamp}-${randomString}`;\n                    const keyName = keyId;\n\n                    const insertSQL = `\n                        INSERT INTO gemini_keys\n                        (id, api_key, name, usage_date, model_usage, category_usage, error_status, consecutive_429_counts, created_at)\n                        VALUES (?, ?, ?, '', '{}', '{}', NULL, '{}', CURRENT_TIMESTAMP)\n                    `;\n\n                    // Insert the key\n                    await configService.runDb(insertSQL, [keyId, trimmedApiKey, keyName]);\n\n                    // Add to the list\n                    newKeyIds.push(keyId);\n\n                    results.push({\n                        key: trimmedApiKey,\n                        success: true,\n                        id: keyId,\n                        name: keyName\n                    });\n                    successCount++;\n\n                    console.log(`Added key ${keyId} to batch.`);\n\n                } catch (keyError) {\n                    if (keyError.message.includes('UNIQUE constraint failed: gemini_keys.api_key')) {\n                        results.push({\n                            key: apiKey,\n                            success: false,\n                            error: 'Duplicate API key.'\n                        });\n                    } else {\n                        results.push({\n                            key: apiKey,\n                            success: false,\n                            error: keyError.message\n                        });\n                    }\n                    failureCount++;\n                    console.error(`Error adding key ${apiKey}:`, keyError);\n                }\n            }\n\n            // Update the key list with all new keys at once\n            if (newKeyIds.length > 0) {\n                currentList.push(...newKeyIds);\n                await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',\n                    ['gemini_key_list', JSON.stringify(currentList)]);\n            }\n\n            // Commit the transaction\n            await configService.runDb('COMMIT');\n\n            console.log(`Batch add completed: ${successCount} successful, ${failureCount} failed.`);\n\n            return { successCount, failureCount, results };\n        } catch (error) {\n            // Rollback transaction on error\n            await configService.runDb('ROLLBACK');\n            console.error(`Transaction error during batch add:`, error);\n            throw error;\n        }\n    }).then(result => {\n        // Sync updates to GitHub outside of serialized operation\n        if (result.successCount > 0) {\n            dbModule.syncToGitHub().catch(err => {\n                console.warn(`Failed to sync to GitHub after batch add:`, err);\n            });\n        }\n        return result;\n    });\n}\n\n/**\n * Deletes a Gemini API key from the database.\n * @param {string} keyId The ID of the key to delete.\n * @returns {Promise<void>}\n */\nasync function deleteGeminiKey(keyId) {\n    if (!keyId || typeof keyId !== 'string' || keyId.trim() === '') {\n        throw new Error('Invalid key ID provided for deletion.');\n    }\n    const trimmedKeyId = keyId.trim();\n\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await configService.serializeDb(async () => {\n        // Use transaction to wrap the entire deletion process to ensure atomicity\n        await configService.runDb('BEGIN TRANSACTION');\n    \n    try {\n        // Check if key exists before deleting\n        const keyExists = await configService.getDb('SELECT id FROM gemini_keys WHERE id = ?', [trimmedKeyId]);\n        if (!keyExists) {\n            await configService.runDb('ROLLBACK');\n            throw new Error(`Key with ID '${trimmedKeyId}' not found.`);\n        }\n\n        // Delete key info from DB\n        await configService.runDb('DELETE FROM gemini_keys WHERE id = ?', [trimmedKeyId]);\n\n        // Remove key ID from the rotation list - get the latest list state\n        const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);\n        let currentList = [];\n        try {\n            currentList = currentListValue ? JSON.parse(currentListValue.value) : [];\n            if (!Array.isArray(currentList)) {\n                console.warn(\"Setting 'gemini_key_list' is not an array during delete, resetting index.\");\n                // Update the index directly within transaction\n                await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', ['gemini_key_index', '0']);\n                await configService.runDb('COMMIT');\n                return; // Can't remove from a non-array list\n            }\n        } catch (e) {\n            console.warn(\"Error parsing gemini_key_list, resetting index:\", e);\n            await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', ['gemini_key_index', '0']);\n            await configService.runDb('COMMIT');\n            return;\n        }\n\n        const initialLength = currentList.length;\n        const newList = currentList.filter(id => id !== trimmedKeyId);\n\n        if (newList.length < initialLength) {\n            // Update the list directly with SQL\n            await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', \n                ['gemini_key_list', JSON.stringify(newList)]);\n            console.log(`Removed key ${trimmedKeyId} from rotation list.`);\n\n            // Get the latest index state and adjust if needed\n            const indexValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_index']);\n            let currentIndex = 0;\n            try {\n                currentIndex = indexValue ? parseInt(indexValue.value) : 0;\n                if (isNaN(currentIndex)) currentIndex = 0;\n            } catch (e) {\n                currentIndex = 0;\n            }\n\n            if (newList.length === 0 || currentIndex >= newList.length) {\n                // Reset index if list is empty or index is out of bounds\n                await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', ['gemini_key_index', '0']);\n            }\n        } else {\n            console.warn(`Key ID ${trimmedKeyId} was not found in the rotation list.`);\n        }\n        \n        // All operations completed successfully, commit the transaction\n        await configService.runDb('COMMIT');\n        console.log(`Deleted Gemini key ${trimmedKeyId} from database.`);\n\n        // GitHub sync outside the transaction (doesn't affect atomicity)\n        await dbModule.syncToGitHub();\n    } catch (error) {\n        // If any error occurs during the process, rollback the transaction\n        await configService.runDb('ROLLBACK');\n        console.error(`Error deleting Gemini key ${trimmedKeyId}:`, error);\n        throw error; // Re-throw the error for upstream handling\n    }\n    });\n}\n\n/**\n * Retrieves all Gemini keys with usage details.\n * @returns {Promise<Array<object>>} Array of key objects.\n */\nasync function getAllGeminiKeysWithUsage() {\n    // Fetch models config and category quotas needed for display logic\n    const [modelsConfig, categoryQuotas] = await Promise.all([\n        configService.getModelsConfig(),\n        configService.getCategoryQuotas()\n    ]);\n\n    const keys = await configService.allDb('SELECT * FROM gemini_keys ORDER BY created_at DESC');\n    const todayInLA = getTodayInLA();\n\n    return keys.map(keyRow => {\n        try {\n            const modelUsageDb = JSON.parse(keyRow.model_usage || '{}');\n            const categoryUsageDb = JSON.parse(keyRow.category_usage || '{}');\n            const consecutive429CountsDb = JSON.parse(keyRow.consecutive_429_counts || '{}');\n\n            const isQuotaReset = keyRow.usage_date !== todayInLA;\n\n            let displayModelUsage = {};\n             // Populate modelUsageData for all relevant models (Custom or Pro/Flash with individualQuota)\n            Object.entries(modelsConfig).forEach(([modelId, modelConfig]) => {\n                let quota = undefined;\n                let shouldInclude = false;\n\n                if (modelConfig.category === 'Custom') {\n                    quota = modelConfig.dailyQuota;\n                    shouldInclude = true; // Always include Custom models\n                } else if ((modelConfig.category === 'Pro' || modelConfig.category === 'Flash') && modelConfig.individualQuota) {\n                    quota = modelConfig.individualQuota;\n                    shouldInclude = true; // Include Pro/Flash if they have individualQuota\n                }\n\n                if (shouldInclude) {\n                    const count = isQuotaReset ? 0 : (modelUsageDb[modelId] || 0);\n                    displayModelUsage[modelId] = {\n                        count: typeof count === 'number' ? count : 0, // Ensure count is a number\n                        quota: quota\n                    };\n                }\n            });\n\n\n            const displayCategoryUsage = isQuotaReset\n                ? { pro: 0, flash: 0 }\n                : {\n                    pro: categoryUsageDb.pro || 0,\n                    flash: categoryUsageDb.flash || 0\n                  };\n\n            // Calculate overall usage for display (sum of category + custom model usage)\n            // This is just for display, not used for actual quota checks\n            let displayTotalUsage = 0;\n            if (!isQuotaReset) {\n                displayTotalUsage = (displayCategoryUsage.pro || 0) + (displayCategoryUsage.flash || 0);\n                Object.values(displayModelUsage).forEach(usage => {\n                    // Only add custom model usage if category is Custom\n                    const modelId = Object.keys(displayModelUsage).find(key => displayModelUsage[key] === usage);\n                    if (modelId && modelsConfig[modelId]?.category === 'Custom') {\n                         displayTotalUsage += usage.count;\n                    }\n                });\n            }\n\n\n            return {\n                id: keyRow.id,\n                name: keyRow.name || keyRow.id,\n                keyPreview: `...${(keyRow.api_key || '').slice(-4)}`,\n                usage: displayTotalUsage, // Display calculated total usage\n                usageDate: keyRow.usage_date || 'N/A',\n                modelUsage: displayModelUsage,\n                categoryUsage: displayCategoryUsage,\n                categoryQuotas: categoryQuotas, // Pass fetched quotas for context\n                errorStatus: keyRow.error_status, // 400, 401, 403, or null\n                consecutive429Counts: consecutive429CountsDb || {}\n            };\n        } catch (e) {\n            console.error(`Error processing key ${keyRow.id}:`, e);\n            return null; // Skip malformed keys\n        }\n    }).filter(k => k !== null);\n}\n\n/**\n * Retrieves keys currently marked with an error status (400, 401 or 403).\n * @returns {Promise<Array<{id: string, name: string, error: number}>>}\n */\nasync function getErrorKeys() {\n    const rows = await configService.allDb('SELECT id, name, error_status FROM gemini_keys WHERE error_status = 400 OR error_status = 401 OR error_status = 403');\n    return rows.map(row => ({\n        id: row.id,\n        name: row.name || row.id,\n        error: row.error_status,\n    }));\n}\n\n/**\n * Clears the error status (sets to NULL) for a specific key.\n * Only performs update and sync if the key actually has an error status.\n * @param {string} keyId The ID of the key to clear the error for.\n * @returns {Promise<boolean>} Returns true if error status was cleared, false if no error status existed.\n */\nasync function clearKeyError(keyId) {\n    let wasCleared = false;\n\n    await configService.serializeDb(async () => {\n        try {\n            // First check if the key has an error status\n            const keyInfo = await configService.getDb('SELECT error_status FROM gemini_keys WHERE id = ?', [keyId]);\n            if (!keyInfo) {\n                throw new Error(`Key with ID '${keyId}' not found for clearing error status.`);\n            }\n\n            // Only update if there's actually an error status to clear\n            if (keyInfo.error_status !== null) {\n                const result = await configService.runDb('UPDATE gemini_keys SET error_status = NULL WHERE id = ?', [keyId]);\n                if (result.changes > 0) {\n                    wasCleared = true;\n                    console.log(`Cleared error status ${keyInfo.error_status} for key ${keyId}.`);\n                }\n            } else {\n                // Key doesn't have an error status, no action needed\n                console.log(`Key ${keyId} has no error status to clear.`);\n            }\n        } catch (error) {\n            console.error(`Error clearing error status for key ${keyId}:`, error);\n            throw error;\n        }\n    });\n\n    // Only sync to GitHub if we actually made changes\n    if (wasCleared) {\n        dbModule.syncToGitHub().catch(err => {\n            console.warn(`Failed to sync to GitHub after clearing error for key ${keyId}:`, err);\n        });\n    }\n\n    return wasCleared;\n}\n\n/**\n * Deletes all keys that have error status (400, 401, or 403).\n * @returns {Promise<{deletedCount: number, deletedKeys: Array<{id: string, name: string}>}>}\n */\nasync function deleteAllErrorKeys() {\n    return await configService.serializeDb(async () => {\n        await configService.runDb('BEGIN TRANSACTION');\n\n        try {\n            // First, get all error keys to return information about what was deleted\n            const errorKeys = await configService.allDb('SELECT id, name FROM gemini_keys WHERE error_status = 400 OR error_status = 401 OR error_status = 403');\n\n            if (errorKeys.length === 0) {\n                await configService.runDb('COMMIT');\n                return { deletedCount: 0, deletedKeys: [] };\n            }\n\n            // Get the error key IDs\n            const errorKeyIds = errorKeys.map(key => key.id);\n\n            // Delete all error keys from the database\n            const placeholders = errorKeyIds.map(() => '?').join(',');\n            const deleteResult = await configService.runDb(\n                `DELETE FROM gemini_keys WHERE id IN (${placeholders})`,\n                errorKeyIds\n            );\n\n            // Update the rotation list - remove all deleted keys\n            const currentListValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_list']);\n            let currentList = [];\n            try {\n                currentList = currentListValue ? JSON.parse(currentListValue.value) : [];\n                if (Array.isArray(currentList)) {\n                    // Filter out deleted keys\n                    const updatedList = currentList.filter(keyId => !errorKeyIds.includes(keyId));\n\n                    if (updatedList.length !== currentList.length) {\n                        await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',\n                            ['gemini_key_list', JSON.stringify(updatedList)]);\n\n                        // Reset index if necessary\n                        const currentIndexValue = await configService.getDb('SELECT value FROM settings WHERE key = ?', ['gemini_key_index']);\n                        let currentIndex = currentIndexValue ? parseInt(currentIndexValue.value, 10) : 0;\n\n                        if (updatedList.length === 0) {\n                            await configService.runDb('DELETE FROM settings WHERE key = ?', ['gemini_key_index']);\n                        } else if (currentIndex >= updatedList.length) {\n                            await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',\n                                ['gemini_key_index', '0']);\n                        }\n                    }\n                }\n            } catch (listError) {\n                console.warn('Error updating rotation list during bulk delete:', listError);\n                // Continue with the transaction, as the main deletion was successful\n            }\n\n            await configService.runDb('COMMIT');\n            console.log(`Deleted ${deleteResult.changes} error keys from database.`);\n\n            // Sync updates to GitHub - outside transaction\n            await dbModule.syncToGitHub();\n\n            return {\n                deletedCount: deleteResult.changes,\n                deletedKeys: errorKeys.map(key => ({\n                    id: key.id,\n                    name: key.name || key.id\n                }))\n            };\n        } catch (error) {\n            await configService.runDb('ROLLBACK');\n            console.error('Error deleting all error keys:', error);\n            throw error;\n        }\n    });\n}\n\n/**\n * Clears error status for all keys that have error status (400, 401, or 403).\n * @returns {Promise<{clearedCount: number, clearedKeys: Array<{id: string, name: string}>}>}\n */\nasync function clearAllErrorKeys() {\n    return await configService.serializeDb(async () => {\n        await configService.runDb('BEGIN TRANSACTION');\n\n        try {\n            // First, get all error keys to return information about what was cleared\n            const errorKeys = await configService.allDb('SELECT id, name, error_status FROM gemini_keys WHERE error_status = 400 OR error_status = 401 OR error_status = 403');\n\n            if (errorKeys.length === 0) {\n                await configService.runDb('COMMIT');\n                return { clearedCount: 0, clearedKeys: [] };\n            }\n\n            // Get the error key IDs\n            const errorKeyIds = errorKeys.map(key => key.id);\n\n            // Clear error status for all error keys\n            const placeholders = errorKeyIds.map(() => '?').join(',');\n            const updateResult = await configService.runDb(\n                `UPDATE gemini_keys SET error_status = NULL WHERE id IN (${placeholders})`,\n                errorKeyIds\n            );\n\n            await configService.runDb('COMMIT');\n            console.log(`Cleared error status for ${updateResult.changes} keys.`);\n\n            // Sync updates to GitHub - outside transaction\n            await dbModule.syncToGitHub();\n\n            return {\n                clearedCount: updateResult.changes,\n                clearedKeys: errorKeys.map(key => ({\n                    id: key.id,\n                    name: key.name || key.id,\n                    previousErrorStatus: key.error_status\n                }))\n            };\n        } catch (error) {\n            await configService.runDb('ROLLBACK');\n            console.error('Error clearing all error keys:', error);\n            throw error;\n        }\n    });\n}\n\n/**\n * Records a persistent error (400/401/403) for a key.\n * @param {string} keyId\n * @param {400 | 401 | 403} status\n * @returns {Promise<void>}\n */\nasync function recordKeyError(keyId, status) {\n    if (status !== 400 && status !== 401 && status !== 403) {\n        console.warn(`Attempted to record invalid error status ${status} for key ${keyId}.`);\n        return;\n    }\n\n    // Use serializeDb to avoid transaction conflicts during batch operations\n    await configService.serializeDb(async () => {\n        try {\n            const result = await configService.runDb(\n                'UPDATE gemini_keys SET error_status = ? WHERE id = ?',\n                [status, keyId]\n            );\n\n            if (result.changes > 0) {\n                console.log(`Recorded error status ${status} for key ${keyId}.`);\n            } else {\n                console.warn(`Cannot record error: Key info not found for ID: ${keyId}`);\n            }\n        } catch (e) {\n            console.error(`Failed to record error status ${status} for key ${keyId}:`, e);\n            // Don't rethrow, recording error is secondary\n        }\n    });\n\n    // Sync updates to GitHub (async, don't wait, outside of serialized operation)\n    dbModule.syncToGitHub().catch(err => {\n        console.warn(`Failed to sync to GitHub after recording error for key ${keyId}:`, err);\n    });\n}\n\n// --- Key Selection and Usage Update Logic ---\n\n/**\n * Selects the next available Gemini API key using round-robin.\n * Skips keys with errors or quota limits reached.\n * @param {string} [requestedModelId] The model being requested, for quota checking.\n * @param {boolean} [updateIndex=true] Whether to update the index in the database. Set to false for read-only operations.\n * @returns {Promise<{ id: string; key: string } | null>} The selected key ID and value, or null if none available.\n */\nasync function getNextAvailableGeminiKey(requestedModelId, updateIndex = true) {\n    try {\n        // 1. Get key list, current index, configs in parallel\n        const [allKeyIds, currentIndexSetting, modelsConfig, categoryQuotas] = await Promise.all([\n            configService.getSetting('gemini_key_list', []),\n            configService.getSetting('gemini_key_index', 0),\n            configService.getModelsConfig(),\n            configService.getCategoryQuotas()\n        ]);\n\n        if (!Array.isArray(allKeyIds) || allKeyIds.length === 0) {\n            console.error(\"No Gemini keys configured in settings 'gemini_key_list'\");\n            return null;\n        }\n\n        // Use transaction for index updates to prevent race conditions\n        let selectedKeyData = null;\n\n        // Wrap transaction operations in serializeDb to prevent conflicts with other operations\n        const executeKeySelection = async () => {\n            // Start transaction if we're going to update the index\n            if (updateIndex) {\n                await configService.runDb('BEGIN TRANSACTION');\n            }\n\n            try {\n            // Get the most current index value within the transaction if updating\n            let currentIndex;\n            if (updateIndex) {\n                const refreshedIndexSetting = await configService.getSetting('gemini_key_index', 0);\n                currentIndex = (typeof refreshedIndexSetting === 'number' && refreshedIndexSetting >= 0) ? \n                    refreshedIndexSetting : 0;\n            } else {\n                currentIndex = (typeof currentIndexSetting === 'number' && currentIndexSetting >= 0) ? \n                    currentIndexSetting : 0;\n            }\n            \n            if (currentIndex >= allKeyIds.length) {\n                currentIndex = 0; // Reset if index is out of bounds\n            }\n\n            // 2. Determine model category for quota checks\n            let modelCategory = undefined;\n            let modelConfig = undefined;\n            if (requestedModelId) {\n                modelConfig = modelsConfig[requestedModelId];\n                if (modelConfig) {\n                    modelCategory = modelConfig.category;\n                } else {\n                    // If model is not configured, infer category from model name\n                    if (requestedModelId.includes('flash')) {\n                        modelCategory = 'Flash';\n                    } else if (requestedModelId.includes('pro')) {\n                        modelCategory = 'Pro';\n                    } else {\n                        // Default to Flash for unknown models (most common case)\n                        modelCategory = 'Flash';\n                    }\n                    console.log(`Model ${requestedModelId} not configured, inferred category: ${modelCategory}`);\n                }\n            }\n\n            // 3. Iterate through keys using round-robin\n            const todayInLA = getTodayInLA();\n            let keysChecked = 0;\n            let initialIndex = currentIndex; // To detect full loop\n\n            while (keysChecked < allKeyIds.length) {\n                const keyId = allKeyIds[currentIndex];\n                keysChecked++;\n\n                const keyInfo = await configService.getDb('SELECT * FROM gemini_keys WHERE id = ?', [keyId]);\n\n                // --- Move index update here to ensure it always happens ---\n                const nextIndex = (currentIndex + 1) % allKeyIds.length;\n\n                // --- Validation Checks ---\n                if (!keyInfo) {\n                    console.warn(`Key ID ${keyId} from list not found in database. Skipping.`);\n                    currentIndex = nextIndex;\n                    continue; // Skip this key if its details aren't in the DB\n                }\n\n                // Check for 400/401/403 error status\n                if (keyInfo.error_status === 400 || keyInfo.error_status === 401 || keyInfo.error_status === 403) {\n                    console.log(`Skipping key ${keyId} due to error status: ${keyInfo.error_status}`);\n                    currentIndex = nextIndex;\n                    continue;\n                }\n\n                // Check quota if model category is known and it's the same day\n                let quotaExceeded = false;\n                if (modelCategory && keyInfo.usage_date === todayInLA) {\n                    try {\n                        const modelUsage = JSON.parse(keyInfo.model_usage || '{}');\n                        const categoryUsage = JSON.parse(keyInfo.category_usage || '{}');\n\n                        switch (modelCategory) {\n                            case 'Pro':\n                                if (modelConfig?.individualQuota) { // Check individual first\n                                    if ((modelUsage[requestedModelId] || 0) >= modelConfig.individualQuota) {\n                                        console.log(`Skipping key ${keyId}: Pro model '${requestedModelId}' individual quota reached (${modelUsage[requestedModelId] || 0}/${modelConfig.individualQuota}).`);\n                                        quotaExceeded = true;\n                                    }\n                                }\n                                if (!quotaExceeded && categoryQuotas.proQuota !== null && (categoryUsage.pro || 0) >= categoryQuotas.proQuota) {\n                                    console.log(`Skipping key ${keyId}: Pro category quota reached (${categoryUsage.pro || 0}/${categoryQuotas.proQuota}).`);\n                                    quotaExceeded = true;\n                                }\n                                break;\n                            case 'Flash':\n                                if (modelConfig?.individualQuota) { // Check individual first\n                                    if ((modelUsage[requestedModelId] || 0) >= modelConfig.individualQuota) {\n                                        console.log(`Skipping key ${keyId}: Flash model '${requestedModelId}' individual quota reached (${modelUsage[requestedModelId] || 0}/${modelConfig.individualQuota}).`);\n                                        quotaExceeded = true;\n                                    }\n                                }\n                                if (!quotaExceeded && categoryQuotas.flashQuota !== null && (categoryUsage.flash || 0) >= categoryQuotas.flashQuota) {\n                                    console.log(`Skipping key ${keyId}: Flash category quota reached (${categoryUsage.flash || 0}/${categoryQuotas.flashQuota}).`);\n                                    quotaExceeded = true;\n                                }\n                                break;\n                            case 'Custom':\n                                if (modelConfig?.dailyQuota !== null && (modelUsage[requestedModelId] || 0) >= modelConfig.dailyQuota) {\n                                    console.log(`Skipping key ${keyId}: Custom model '${requestedModelId}' quota reached (${modelUsage[requestedModelId] || 0}/${modelConfig.dailyQuota}).`);\n                                    quotaExceeded = true;\n                                }\n                                break;\n                        }\n                    } catch (parseError) {\n                        console.error(`Error parsing usage JSON for key ${keyId}. Skipping quota check. Error:`, parseError);\n                        // Optionally skip the key entirely if parsing fails\n                    }\n                }\n\n                if (quotaExceeded) {\n                    currentIndex = nextIndex;\n                    continue; // Skip this key\n                }\n\n                // If we reach here, the key is valid\n                selectedKeyData = { id: keyInfo.id, key: keyInfo.api_key };\n                currentIndex = nextIndex; // Set index for the *next* request\n                break; // Found a valid key\n            } // End while loop\n\n            // --- Post-selection Updates ---\n            if (!selectedKeyData) {\n                if (updateIndex) {\n                    await configService.runDb('ROLLBACK'); // Rollback if no key found\n                }\n                console.error(\"No available Gemini keys found after checking all keys.\");\n                return null;\n            }\n\n            // Only update indices if updateIndex is true (for API operations)\n            // Skip for read-only operations like fetching model lists\n            if (updateIndex) {\n                // Save the next index for the subsequent request within the transaction\n                // Use direct SQL to avoid nested transactions\n                await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', \n                    ['gemini_key_index', String(currentIndex)]);\n                await configService.runDb('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', \n                    ['last_used_gemini_key_id', selectedKeyData.id]);\n                \n                // Commit the transaction\n                await configService.runDb('COMMIT');\n                \n                // GitHub sync outside transaction\n                await dbModule.syncToGitHub();\n                console.log(`Selected Gemini Key ID via sequential round-robin: ${selectedKeyData.id} (next index will be: ${currentIndex})`);\n            } else {\n                console.log(`Selected Gemini Key ID (read-only): ${selectedKeyData.id} (index not updated)`);\n            }\n            \n            return selectedKeyData;\n            \n            } catch (error) {\n                // If any error occurs and we're in a transaction, rollback\n                if (updateIndex) {\n                    await configService.runDb('ROLLBACK');\n                }\n                throw error; // Re-throw to be caught by outer try/catch\n            }\n        };\n\n        // Execute key selection with or without serialization based on updateIndex\n        if (updateIndex) {\n            selectedKeyData = await configService.serializeDb(executeKeySelection);\n        } else {\n            selectedKeyData = await executeKeySelection();\n        }\n\n        return selectedKeyData;\n\n    } catch (error) {\n        console.error(\"Error retrieving or processing Gemini keys:\", error);\n        return null;\n    }\n}\n\n\n/**\n * Increments the usage count for a given Gemini Key ID. Resets if the date changes.\n * Tracks usage per model and per category. Resets 429 counters on success.\n * @param {string} keyId\n * @param {string} [modelId]\n * @param {'Pro' | 'Flash' | 'Custom'} [category]\n * @returns {Promise<void>}\n */\nasync function incrementKeyUsage(keyId, modelId, category) {\n    await configService.serializeDb(async () => {\n        try {\n            // Get the most current key data\n            const keyRow = await configService.getDb('SELECT usage_date, model_usage, category_usage, consecutive_429_counts FROM gemini_keys WHERE id = ?', [keyId]);\n            if (!keyRow) {\n                console.warn(`Cannot increment usage: Key info not found for ID: ${keyId}`);\n                return;\n            }\n\n            const todayInLA = getTodayInLA();\n            let modelUsage = JSON.parse(keyRow.model_usage || '{}');\n            let categoryUsage = JSON.parse(keyRow.category_usage || '{}');\n            let consecutive429Counts = {}; // Reset 429 on successful usage increment\n            let usageDate = keyRow.usage_date;\n\n            // Reset counters if it's a new day\n            if (usageDate !== todayInLA) {\n                console.log(`Date change detected for key ${keyId} (${usageDate} → ${todayInLA}). Resetting usage.`);\n                usageDate = todayInLA;\n                modelUsage = {};\n                categoryUsage = { pro: 0, flash: 0 };\n                // 429 counts are already reset above\n            }\n\n            // Increment model-specific usage\n            if (modelId) {\n                modelUsage[modelId] = (modelUsage[modelId] || 0) + 1;\n            }\n\n            // Increment category-specific usage\n            if (category === 'Pro') {\n                categoryUsage.pro = (categoryUsage.pro || 0) + 1;\n            } else if (category === 'Flash') {\n                categoryUsage.flash = (categoryUsage.flash || 0) + 1;\n            }\n\n            // Update the database (serializeDb provides atomicity)\n            const sql = `\n                UPDATE gemini_keys\n                SET usage_date = ?, model_usage = ?, category_usage = ?, consecutive_429_counts = ?\n                WHERE id = ?\n            `;\n            await configService.runDb(sql, [\n                usageDate,\n                JSON.stringify(modelUsage),\n                JSON.stringify(categoryUsage),\n                JSON.stringify(consecutive429Counts), // Store empty object (reset counters)\n                keyId\n            ]);\n\n            console.log(`Usage for key ${keyId} updated. Date: ${usageDate}, Model: ${modelId} (${category}), Models: ${JSON.stringify(modelUsage)}, Categories: ${JSON.stringify(categoryUsage)}, 429Counts reset.`);\n\n        } catch (e) {\n            console.error(`Failed to increment usage for key ${keyId}:`, e);\n            // Don't rethrow, allow request to potentially succeed anyway\n        }\n    });\n\n    // Sync updates to GitHub (async, outside of serialized operation)\n    dbModule.syncToGitHub().catch(err => {\n        console.warn(`Failed to sync to GitHub after incrementing usage for key ${keyId}:`, err);\n    });\n}\n\n/**\n * Forces the usage count for a specific category/model on a key to its configured limit.\n * Resets the specific 429 counter that triggered the limit.\n * @param {string} keyId\n * @param {'Pro' | 'Flash' | 'Custom'} category\n * @param {string} [modelId] Optional model ID (required for Custom or Pro/Flash with individual quota).\n * @param {string} [counterKey] The specific counter key (e.g., 'model-id' or 'category:pro') to reset.\n * @returns {Promise<void>}\n */\nasync function forceSetQuotaToLimit(keyId, category, modelId, counterKey) {\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await configService.serializeDb(async () => {\n        // Start a transaction for atomic update\n        await configService.runDb('BEGIN TRANSACTION');\n    \n    try {\n        // Fetch current key info and configs\n        // Get models and quotas outside the transaction as they don't need to be transactional\n        const [modelsConfig, categoryQuotas] = await Promise.all([\n            configService.getModelsConfig(),\n            configService.getCategoryQuotas()\n        ]);\n        \n        // Get the latest key data within transaction\n        const keyRow = await configService.getDb('SELECT usage_date, model_usage, category_usage, consecutive_429_counts FROM gemini_keys WHERE id = ?', [keyId]);\n\n        if (!keyRow) {\n            await configService.runDb('ROLLBACK');\n            console.warn(`Cannot force quota limit: Key info not found for ID: ${keyId}`);\n            return;\n        }\n\n        const todayInLA = getTodayInLA();\n        let modelUsage = JSON.parse(keyRow.model_usage || '{}');\n        let categoryUsage = JSON.parse(keyRow.category_usage || '{}');\n        let consecutive429Counts = JSON.parse(keyRow.consecutive_429_counts || '{}');\n        let usageDate = keyRow.usage_date;\n\n        // Reset usage if date changed\n        if (usageDate !== todayInLA) {\n            console.log(`Date change detected in forceSetQuotaToLimit for key ${keyId}. Resetting usage before forcing.`);\n            usageDate = todayInLA;\n            modelUsage = {};\n            categoryUsage = { pro: 0, flash: 0 };\n            consecutive429Counts = {}; // Also reset 429 counts on date change\n        }\n\n        // Reset the specific 429 counter\n        if (counterKey && consecutive429Counts.hasOwnProperty(counterKey)) {\n            console.log(`Resetting 429 counter for key ${keyId}, counter ${counterKey} after forcing quota.`);\n            delete consecutive429Counts[counterKey];\n        }\n\n        // Determine the limit and update the relevant usage counter\n        let quotaLimit = Infinity;\n        const modelConfig = modelId ? modelsConfig[modelId] : undefined;\n        let updated = false;\n\n        switch (category) {\n            case 'Pro':\n                if (modelId && modelConfig?.individualQuota) {\n                    quotaLimit = modelConfig.individualQuota;\n                    modelUsage[modelId] = quotaLimit;\n                    console.log(`Forcing Pro model ${modelId} individual usage for key ${keyId} to limit: ${quotaLimit}`);\n                    updated = true;\n                } else if (categoryQuotas.proQuota !== null) {\n                    quotaLimit = categoryQuotas.proQuota;\n                    categoryUsage.pro = quotaLimit;\n                    console.log(`Forcing Pro category usage for key ${keyId} to limit: ${quotaLimit}`);\n                    updated = true;\n                }\n                break;\n            case 'Flash':\n                if (modelId && modelConfig?.individualQuota) {\n                    quotaLimit = modelConfig.individualQuota;\n                    modelUsage[modelId] = quotaLimit;\n                    console.log(`Forcing Flash model ${modelId} individual usage for key ${keyId} to limit: ${quotaLimit}`);\n                    updated = true;\n                } else if (categoryQuotas.flashQuota !== null) {\n                    quotaLimit = categoryQuotas.flashQuota;\n                    categoryUsage.flash = quotaLimit;\n                    console.log(`Forcing Flash category usage for key ${keyId} to limit: ${quotaLimit}`);\n                    updated = true;\n                }\n                break;\n            case 'Custom':\n                if (modelId && modelConfig?.dailyQuota !== null) {\n                    quotaLimit = modelConfig.dailyQuota;\n                    modelUsage[modelId] = quotaLimit;\n                    console.log(`Forcing Custom model ${modelId} usage for key ${keyId} to limit: ${quotaLimit}`);\n                    updated = true;\n                } else if (!modelId) {\n                    console.warn(`Cannot force quota limit for Custom category without modelId.`);\n                }\n                break;\n        }\n\n        if (!updated) {\n            console.warn(`No relevant quota found to force for key ${keyId}, category ${category}, model ${modelId}.`);\n            // Still save potential reset of 429 counter if counterKey was provided\n            if (counterKey) {\n                await configService.runDb(\n                    'UPDATE gemini_keys SET consecutive_429_counts = ? WHERE id = ?',\n                    [JSON.stringify(consecutive429Counts), keyId]\n                );\n            }\n            await configService.runDb('COMMIT'); // Still commit the transaction\n            return;\n        }\n\n        // Update the database within transaction\n        const sql = `\n            UPDATE gemini_keys\n            SET usage_date = ?, model_usage = ?, category_usage = ?, consecutive_429_counts = ?\n            WHERE id = ?\n        `;\n        await configService.runDb(sql, [\n            usageDate,\n            JSON.stringify(modelUsage),\n            JSON.stringify(categoryUsage),\n            JSON.stringify(consecutive429Counts),\n            keyId\n        ]);\n        \n        // Commit the transaction\n        await configService.runDb('COMMIT');\n        \n        console.log(`Key ${keyId} quota forced for category ${category}${modelId ? ` (model: ${modelId})` : ''} for date ${usageDate}.`);\n\n        // Sync updates to GitHub outside transaction\n        await dbModule.syncToGitHub();\n    } catch (e) {\n        // Rollback on error\n        await configService.runDb('ROLLBACK');\n        console.error(`Failed to force quota limit for key ${keyId}:`, e);\n    }\n    });\n}\n\n/**\n * Handles 429 errors: increments counter, forces quota limit if threshold reached.\n * @param {string} keyId\n * @param {'Pro' | 'Flash' | 'Custom'} category\n * @param {string} [modelId] Optional model ID.\n * @param {object | string} [errorDetails] Optional error object/string from Gemini, used to check for quotaId.\n * @returns {Promise<void>}\n */\nasync function handle429Error(keyId, category, modelId, errorDetails) {\n    const CONSECUTIVE_429_LIMIT = 3;\n\n    // Determine if quota exceeded based on quotaId field\n    const quotaId = typeof errorDetails === 'object' && errorDetails !== null ? errorDetails.quotaId : null;\n    const isQuotaExceeded = typeof quotaId === 'string' && quotaId.toLowerCase().includes(\"perday\");\n\n    // If it's a regular 429 (not quota exceeded), do nothing and return. Retry is handled by the caller.\n    if (!isQuotaExceeded) {\n        console.log(`Received regular 429 for key ${keyId}. Ignoring counter, retry will be handled by caller if applicable.`);\n        return;\n    }\n\n    // --- Handle Quota Exceeded 429 ---\n    console.warn(`Received quota-exceeded 429 for key ${keyId}. Proceeding with counter logic.`);\n\n    // Use serializeDb to ensure atomic operations and avoid concurrency issues\n    await configService.serializeDb(async () => {\n        let transactionCommitted = false; // Flag to prevent double commit/rollback in finally block if forceSetQuotaToLimit is called\n        try {\n            // Start transaction only if we are processing a quota-exceeded error\n            await configService.runDb('BEGIN TRANSACTION');\n\n        // Get models and quotas (can stay outside transaction)\n        const [modelsConfig, categoryQuotas] = await Promise.all([\n            configService.getModelsConfig(),\n            configService.getCategoryQuotas()\n        ]);\n\n        // Get key data within transaction\n        const keyRow = await configService.getDb('SELECT consecutive_429_counts FROM gemini_keys WHERE id = ?', [keyId]);\n\n        if (!keyRow) {\n            await configService.runDb('ROLLBACK');\n            console.warn(`Cannot handle quota 429: Key info not found for ID: ${keyId}`);\n            return;\n        }\n\n        let consecutive429Counts = JSON.parse(keyRow.consecutive_429_counts || '{}');\n\n        // Determine the counter key and if a relevant quota exists\n        // Use keyId as prefix to ensure each key has its own independent counter\n        let counterKey = undefined;\n        let needsQuotaCheck = false; // Still useful to check if a quota is actually configured\n        const modelConfig = modelId ? modelsConfig[modelId] : undefined;\n\n        if (category === 'Custom' && modelId) {\n            counterKey = `${keyId}-${modelId}`; // Prefix with keyId for uniqueness\n            needsQuotaCheck = !!modelConfig?.dailyQuota;\n        } else if ((category === 'Pro' || category === 'Flash') && modelId && modelConfig?.individualQuota) {\n            counterKey = `${keyId}-${modelId}`; // Prefix with keyId for uniqueness\n            needsQuotaCheck = true; // Individual quota exists\n        } else if (category === 'Pro') {\n            counterKey = `${keyId}-category:pro`; // Prefix with keyId for uniqueness\n            needsQuotaCheck = !!categoryQuotas?.proQuota && isFinite(categoryQuotas.proQuota);\n        } else if (category === 'Flash') {\n            counterKey = `${keyId}-category:flash`; // Prefix with keyId for uniqueness\n            needsQuotaCheck = !!categoryQuotas?.flashQuota && isFinite(categoryQuotas.flashQuota);\n        }\n\n        if (!counterKey) {\n            await configService.runDb('ROLLBACK');\n            console.warn(`Could not determine counter key for quota 429 handling (key ${keyId}, category ${category}, model ${modelId}).`);\n            return;\n        }\n\n        // Only proceed if a relevant quota is actually configured for this limit type\n        if (!needsQuotaCheck) {\n            await configService.runDb('COMMIT'); // Commit as no changes needed, but avoids rollback error\n            console.log(`Skipping quota-exceeded 429 counter for key ${keyId}, counter ${counterKey} as no relevant quota is configured.`);\n            return;\n        }\n\n        // Increment counter for the specific quota key\n        const currentCount = (consecutive429Counts[counterKey] || 0) + 1;\n        consecutive429Counts[counterKey] = currentCount;\n\n        console.warn(`Quota-exceeded 429 for key ${keyId}, counter ${counterKey}. Consecutive count: ${currentCount}`);\n\n        // Check if the threshold is reached\n        if (currentCount >= CONSECUTIVE_429_LIMIT) {\n            // Commit the current transaction *before* calling forceSetQuotaToLimit,\n            // as it starts its own transaction.\n            await configService.runDb('COMMIT');\n            transactionCommitted = true; // Mark as committed\n\n            console.warn(`Consecutive quota-exceeded 429 limit (${CONSECUTIVE_429_LIMIT}) reached for key ${keyId}, counter ${counterKey}. Forcing quota limit.`);\n            // forceSetQuotaToLimit handles the counter reset and its own transaction.\n            await forceSetQuotaToLimit(keyId, category, modelId, counterKey);\n\n        } else {\n            // Limit not reached, just update the count within this transaction\n            await configService.runDb(\n                'UPDATE gemini_keys SET consecutive_429_counts = ? WHERE id = ?',\n                [JSON.stringify(consecutive429Counts), keyId]\n            );\n            // Commit the transaction\n            await configService.runDb('COMMIT');\n            transactionCommitted = true; // Mark as committed\n        }\n\n    } catch (e) {\n        console.error(`Failed to handle quota 429 error for key ${keyId}:`, e);\n        // Attempt to rollback if transaction wasn't already committed\n        if (!transactionCommitted) {\n            try {\n                await configService.runDb('ROLLBACK');\n            } catch (rollbackError) {\n                console.error(`Error during rollback after failed 429 handling for key ${keyId}:`, rollbackError);\n            }\n        }\n        // Do not rethrow, allow processing to continue if possible\n    }\n    });\n}\n\n\nmodule.exports = {\n    addGeminiKey,\n    addMultipleGeminiKeys,\n    deleteGeminiKey,\n    getAllGeminiKeysWithUsage,\n    getNextAvailableGeminiKey,\n    incrementKeyUsage,\n    handle429Error,\n    recordKeyError,\n    getErrorKeys,\n    clearKeyError,\n    deleteAllErrorKeys,\n    clearAllErrorKeys,\n};\n"
  },
  {
    "path": "src/services/geminiProxyService.js",
    "content": "const fetch = require('node-fetch');\nconst { Readable } = require('stream');\nconst { URL } = require('url'); // Import URL for parsing remains relevant for potential future URL parsing\nconst dbModule = require('../db');\nconst configService = require('./configService');\nconst geminiKeyService = require('./geminiKeyService');\nconst transformUtils = require('../utils/transform');\nconst proxyPool = require('../utils/proxyPool'); // Import the new proxy pool module\n\n\n// Base Gemini API URL\nconst BASE_GEMINI_URL = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com';\n\n// Helper function to check if a 400 error should be marked for key error\nfunction shouldMark400Error(errorObject) {\n    try {\n        // Only mark 400 errors if the message indicates invalid API key\n        if (errorObject && errorObject.message) {\n            const errorMessage = errorObject.message;\n\n            // Check for the specific \"API key not valid\" error\n            if (errorMessage && errorMessage.includes('API key not valid. Please pass a valid API key.')) {\n                return true;\n            }\n        }\n        return false;\n    } catch (e) {\n        // If we can't parse the error, don't mark it\n        return false;\n    }\n}\n\nasync function proxyChatCompletions(openAIRequestBody, workerApiKey, stream, thinkingBudget, keepAliveCallback = null) {\n    const requestedModelId = openAIRequestBody?.model;\n\n    if (!requestedModelId) {\n        return { error: { message: \"Missing 'model' field in request body\" }, status: 400 };\n    }\n    if (!openAIRequestBody.messages || !Array.isArray(openAIRequestBody.messages)) {\n        return { error: { message: \"Missing or invalid 'messages' field in request body\" }, status: 400 };\n    }\n\n    let lastError = null;\n    let lastErrorStatus = 500;\n    let modelInfo;\n    let modelCategory;\n    let isSafetyEnabled;\n    let modelsConfig;\n    let MAX_RETRIES;\n    let keepAliveEnabled;\n\n    try {\n        // Fetch model config, safety settings, max retry setting, and keepalive setting from database\n        [modelsConfig, isSafetyEnabled, MAX_RETRIES, keepAliveEnabled] = await Promise.all([\n            configService.getModelsConfig(),\n            configService.getWorkerKeySafetySetting(workerApiKey), // Get safety setting for this worker key\n            configService.getSetting('max_retry', '3').then(val => parseInt(val) || 3),\n            configService.getSetting('keepalive', '0').then(val => String(val) === '1')\n        ]);\n\n        console.log(`Using MAX_RETRIES: ${MAX_RETRIES} (from database)`);\n        console.log(`KEEPALIVE settings - keepAliveEnabled: ${keepAliveEnabled}, stream: ${stream}, isSafetyEnabled: ${isSafetyEnabled}`);\n\n        // Check if web search functionality needs to be added\n        // 1. Via web_search parameter or 2. Using a model ending with -search\n        const isSearchModel = requestedModelId.endsWith('-search');\n        const actualModelId = isSearchModel ? requestedModelId.replace('-search', '') : requestedModelId;\n\n        // If KEEPALIVE is enabled, this is a streaming request, and safety is disabled, we'll handle it specially\n        const useKeepAlive = keepAliveEnabled && stream && !isSafetyEnabled;\n        console.log(`KEEPALIVE useKeepAlive decision: ${useKeepAlive}`);\n    \n        // If using keepalive, we'll make a non-streaming request to Gemini but send streaming responses to client\n        const actualStreamMode = useKeepAlive ? false : stream;\n\n        // If it's a search model, use the original model ID to find model info\n        const modelLookupId = isSearchModel ? actualModelId : requestedModelId;\n        modelInfo = modelsConfig[modelLookupId];\n        if (!modelInfo) {\n            // If model is not configured, infer category from model name\n            let inferredCategory;\n            if (modelLookupId.includes('flash')) {\n                inferredCategory = 'Flash';\n            } else if (modelLookupId.includes('pro')) {\n                inferredCategory = 'Pro';\n            } else {\n                // Default to Flash for unknown models (most common case)\n                inferredCategory = 'Flash';\n            }\n            console.log(`Model ${modelLookupId} not configured, inferred category: ${inferredCategory}`);\n\n            // Create a temporary model info object\n            modelInfo = { category: inferredCategory };\n            modelCategory = inferredCategory;\n        } else {\n            modelCategory = modelInfo.category;\n        }\n\n        // --- Retry Loop ---\n        for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {\n            let selectedKey;\n            try {\n                // 1. Get Key inside the loop for each attempt\n                // If it's a search model, use the original model ID to get the API key\n                const keyModelId = isSearchModel ? actualModelId : requestedModelId;\n                \n                // If previous attempt had an empty response, force getting a new key by calling getNextAvailableGeminiKey\n                selectedKey = await geminiKeyService.getNextAvailableGeminiKey(keyModelId);\n\n                // 2. Validate Key\n                if (!selectedKey) {\n                    console.error(`Attempt ${attempt}: No available Gemini API Key found.`);\n                    if (attempt === 1) {\n                        // If no key on first try, return 503 immediately\n                        return { error: { message: \"No available Gemini API Key configured or all keys are currently rate-limited/invalid.\" }, status: 503 };\n                    } else {\n                        // If no key on subsequent tries (after 429), return the last recorded 429 error\n                         console.error(`Attempt ${attempt}: No more keys to try after previous 429.`);\n                         return { error: lastError, status: lastErrorStatus };\n                    }\n                }\n\n                console.log(`Attempt ${attempt}: Proxying request for model: ${requestedModelId}, Category: ${modelCategory}, KeyID: ${selectedKey.id}, Safety: ${isSafetyEnabled}`);\n\n                // 3. Transform Request Body (includes tool_choice support)\n                const { contents, systemInstruction, tools: geminiTools, toolConfig } = transformUtils.transformOpenAiToGemini(\n                    openAIRequestBody,\n                    requestedModelId,\n                    isSafetyEnabled // Pass safety setting to transformer\n                );\n\n                if (contents.length === 0 && !systemInstruction) {\n                    return { error: { message: \"Request must contain at least one user or assistant message.\" }, status: 400 };\n                }\n\n                const geminiRequestBody = {\n                    contents: contents,\n                    generationConfig: {\n                        ...(openAIRequestBody.temperature !== undefined && { temperature: openAIRequestBody.temperature }),\n                        ...(openAIRequestBody.top_p !== undefined && { topP: openAIRequestBody.top_p }),\n                        ...(openAIRequestBody.max_tokens !== undefined && { maxOutputTokens: openAIRequestBody.max_tokens }),\n                        ...(openAIRequestBody.stop && { stopSequences: Array.isArray(openAIRequestBody.stop) ? openAIRequestBody.stop : [openAIRequestBody.stop] }),\n                        ...(thinkingBudget !== undefined && { thinkingConfig: { thinkingBudget: thinkingBudget } }),\n                    },\n                    ...(geminiTools && { tools: geminiTools }),\n                    ...(toolConfig && { toolConfig: toolConfig }),\n                    ...(systemInstruction && { systemInstruction: systemInstruction }),\n                };\n\n                if (openAIRequestBody.web_search === 1 || isSearchModel) {\n                    console.log(`Web search enabled for this request (${isSearchModel ? 'model-based' : 'parameter-based'})`);\n                    \n                    // Create Google Search tool\n                    const googleSearchTool = {\n                        googleSearch: {}\n                    };\n                    \n                    // Add to existing tools or create a new tools array\n                    if (geminiRequestBody.tools) {\n                        geminiRequestBody.tools = [...geminiRequestBody.tools, googleSearchTool];\n                    } else {\n                        geminiRequestBody.tools = [googleSearchTool];\n                    }\n                    \n                    // Add a prompt at the end of the request to encourage the model to use search tools\n                    geminiRequestBody.contents.push({\n                        role: 'user',\n                        parts: [{ text: '(Use search tools to get the relevant information and complete this request.)' }]\n                    });\n                }\n\n                if (!isSafetyEnabled) {\n                    geminiRequestBody.safetySettings = [\n                        { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' }, \n                        { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' }, \n                        { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' }, \n                        { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' }, \n                        { category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'BLOCK_NONE' }, \n                    ];\n                     console.log(\"Applying safety settings.\");\n                }\n\n                // 4. Prepare and Send Request to Gemini\n                // If keepalive is enabled and original request was streaming, use non-streaming API\n                const apiAction = actualStreamMode ? 'streamGenerateContent' : 'generateContent';\n\n                // Build complete API URL using the base URL\n                // Use actualModelId instead of requestedModelId with -search suffix\n                const geminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${actualModelId}:${apiAction}`;\n\n                const geminiRequestHeaders = {\n                    'Content-Type': 'application/json',\n                    'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36`,\n                    'X-Accel-Buffering': 'no',\n                    'Cache-Control': 'no-cache, no-store, must-revalidate',\n                    'Pragma': 'no-cache',\n                    'Expires': '0',\n                    'x-goog-api-key': selectedKey.key\n                };\n\n                // Get the next proxy agent for this request\n                const agent = proxyPool.getNextProxyAgent(); // Use function from imported module\n\n                // Log proxy usage here if an agent is obtained\n                const logSuffix = agent ? ` via proxy ${agent.proxy.href}` : ''; // Get proxy URL from agent if available\n                console.log(`Attempt ${attempt}: Sending ${actualStreamMode ? 'streaming' : 'non-streaming'} request to Gemini URL: ${geminiUrl}${logSuffix}`);\n                \n                // Log if using keepalive mode\n                if (keepAliveEnabled && stream) {\n                    if (useKeepAlive) {\n                        console.log(`Using KEEPALIVE mode: Client expects stream but sending non-streaming request to Gemini (Safety disabled)`);\n                    } else {\n                        console.log(`KEEPALIVE is enabled but safety is also enabled. Using normal streaming mode.`);\n                    }\n                }\n\n                const fetchOptions = { // Create options object\n                    method: 'POST',\n                    headers: geminiRequestHeaders,\n                    body: JSON.stringify(geminiRequestBody),\n                    size: 100 * 1024 * 1024,\n                    timeout: 300000\n                };\n\n                // Add agent to options only if it's defined\n                if (agent) {\n                    fetchOptions.agent = agent;\n                }\n\n                // For KEEPALIVE mode, handle the request asynchronously to avoid blocking\n                // If using keepalive, handle it asynchronously with its own retry logic inside.\n                // This is because the main retry loop is synchronous and we need to return immediately.\n                if (useKeepAlive && keepAliveCallback) {\n                    \n                    const keepAliveRunner = async () => {\n                        console.log('KEEPALIVE: Starting heartbeat and asynchronous request process.');\n                        keepAliveCallback.startHeartbeat();\n\n                        let lastKeepAliveError = null;\n                        let lastKeepAliveStatus = 500;\n\n                        for (let kAttempt = 1; kAttempt <= MAX_RETRIES; kAttempt++) {\n                            let keepAliveKey;\n                            try {\n                                const keyModelId = isSearchModel ? actualModelId : requestedModelId;\n                                keepAliveKey = await geminiKeyService.getNextAvailableGeminiKey(keyModelId);\n\n                                if (!keepAliveKey) {\n                                    lastKeepAliveError = { message: \"No available Gemini API Key for keepalive retry.\" };\n                                    lastKeepAliveStatus = 503;\n                                    console.error(`KEEPALIVE Attempt ${kAttempt}: No more keys to try.`);\n                                    continue; // Try to find a key in the next attempt\n                                }\n                                \n                                const currentGeminiUrl = `${BASE_GEMINI_URL}/v1beta/models/${actualModelId}:generateContent`;\n                                const currentFetchOptions = {\n                                    ...fetchOptions,\n                                    headers: { ...fetchOptions.headers, 'x-goog-api-key': keepAliveKey.key },\n                                    agent: proxyPool.getNextProxyAgent()\n                                };\n                                const logSuffix = currentFetchOptions.agent ? ` via proxy ${currentFetchOptions.agent.proxy.href}` : '';\n                                console.log(`KEEPALIVE Attempt ${kAttempt}: Sending request to ${currentGeminiUrl}${logSuffix} with key ID ${keepAliveKey.id}`);\n\n                                const geminiResponse = await fetch(currentGeminiUrl, currentFetchOptions);\n\n                                if (!geminiResponse.ok) {\n                                    const errorBodyText = await geminiResponse.text();\n                                    lastKeepAliveStatus = geminiResponse.status;\n                                    try {\n                                        lastKeepAliveError = JSON.parse(errorBodyText).error || { message: errorBodyText };\n                                    } catch {\n                                        lastKeepAliveError = { message: errorBodyText };\n                                    }\n                                    console.error(`KEEPALIVE Attempt ${kAttempt}: Gemini API error ${geminiResponse.status}:`, lastKeepAliveError.message);\n                                    \n                                    // Handle key errors for retry\n                                     if (geminiResponse.status === 429) {\n                                        geminiKeyService.handle429Error(keepAliveKey.id, modelCategory, actualModelId, lastKeepAliveError).catch(e => console.error(\"BG 429 Error:\", e));\n                                    } else if (geminiResponse.status === 400 && shouldMark400Error(lastKeepAliveError)) {\n                                        geminiKeyService.recordKeyError(keepAliveKey.id, 400).catch(e => console.error(\"BG 400 Error:\", e));\n                                    } else if ([401, 403, 500].includes(geminiResponse.status)) {\n                                         geminiKeyService.recordKeyError(keepAliveKey.id, geminiResponse.status).catch(e => console.error(\"BG Key Error:\", e));\n                                    }\n                                    \n                                    // Continue to next attempt if not the last one\n                                    if (kAttempt < MAX_RETRIES) {\n                                         console.warn(`KEEPALIVE Attempt ${kAttempt} failed. Retrying...`);\n                                         continue;\n                                    } else {\n                                        // Last attempt failed, break loop to send error\n                                        break;\n                                    }\n                                }\n                                \n                                // Success case\n                                const geminiResponseData = await geminiResponse.json();\n                                geminiKeyService.incrementKeyUsage(keepAliveKey.id, actualModelId, modelCategory).catch(e => console.error(\"BG Usage Error:\", e));\n                                console.log(`KEEPALIVE: Request successful on attempt ${kAttempt}. Stopping heartbeat.`);\n                                keepAliveCallback.stopHeartbeat();\n                                keepAliveCallback.sendFinalResponse(geminiResponseData);\n                                return; // Exit the runner function on success\n\n                            } catch (fetchError) {\n                                lastKeepAliveError = { message: `Internal Proxy Error during keepalive fetch: ${fetchError.message}`, type: 'proxy_internal_error' };\n                                lastKeepAliveStatus = 500;\n                                console.error(`KEEPALIVE Attempt ${kAttempt}: Fetch error:`, fetchError);\n                                // Don't retry on network errors, just fail\n                                break;\n                            }\n                        }\n                        \n                        // If loop finishes, all retries have failed\n                        console.error(`KEEPALIVE: All ${MAX_RETRIES} attempts failed. Sending last error.`);\n                        keepAliveCallback.stopHeartbeat();\n                        keepAliveCallback.sendError(lastKeepAliveError || { message: \"All keepalive attempts failed.\" });\n                    };\n\n                    keepAliveRunner(); // Run the async function\n\n                    // Return immediately to the client, while keepAliveRunner works in the background\n                    return {\n                        isKeepAlive: true,\n                        // Note: selectedKeyId is not definitively known here, as it's selected inside the async runner.\n                        // We can return the first-attempt key, or null. Let's return the one from the main loop's current attempt.\n                        selectedKeyId: selectedKey.id,\n                        modelCategory: modelCategory,\n                        requestedModelId: requestedModelId\n                    };\n                }\n\n                const geminiResponse = await fetch(geminiUrl, fetchOptions); // Use fetchOptions for non-KEEPALIVE mode\n\n                // 5. Handle Gemini Response Status and Errors\n                if (!geminiResponse.ok) {\n                    const errorBodyText = await geminiResponse.text();\n                    console.error(`Attempt ${attempt}: Gemini API error: ${geminiResponse.status} ${geminiResponse.statusText}`, errorBodyText);\n\n                    lastErrorStatus = geminiResponse.status; // Store status\n                    try {\n                        lastError = JSON.parse(errorBodyText).error || { message: errorBodyText }; // Try parsing, fallback to text\n                    } catch {\n                        lastError = { message: errorBodyText };\n                    }\n                     // Add type and code if not present from Gemini\n                    if (!lastError.type) lastError.type = `gemini_api_error_${geminiResponse.status}`;\n                    if (!lastError.code) lastError.code = geminiResponse.status;\n\n\n                    // Handle all errors with retry mechanism\n                    if (geminiResponse.status === 429) {\n                        // Pass the full parsed error object (lastError) which may contain quotaId\n                        console.log(`429 error details: ${JSON.stringify(lastError)}`);\n\n                        // Record 429 for the key - use actualModelId for consistent counting\n                        geminiKeyService.handle429Error(selectedKey.id, modelCategory, actualModelId, lastError)\n                            .catch(err => console.error(`Error handling 429 for key ${selectedKey.id} in background:`, err));\n                    } else if (geminiResponse.status === 401 || geminiResponse.status === 403) {\n                        // Record persistent error for the key\n                        geminiKeyService.recordKeyError(selectedKey.id, geminiResponse.status)\n                             .catch(err => console.error(`Error recording key error ${geminiResponse.status} for key ${selectedKey.id} in background:`, err));\n                    } else if (geminiResponse.status === 400) {\n                        // Check if this is an invalid API key 400 error that should be marked\n                        console.log(`400 error details: ${JSON.stringify(lastError)}`);\n                        if (shouldMark400Error(lastError)) {\n                            geminiKeyService.recordKeyError(selectedKey.id, geminiResponse.status)\n                                .catch(err => console.error(`Error recording key error ${geminiResponse.status} for key ${selectedKey.id} in background:`, err));\n                        } else {\n                            console.log(`Skipping error marking for key ${selectedKey.id} - 400 error not related to invalid API key.`);\n                        }\n                    } else {\n                        // Record error for other status codes (500, etc.)\n                        console.log(`${geminiResponse.status} error details: ${JSON.stringify(lastError)}`);\n                        geminiKeyService.recordKeyError(selectedKey.id, geminiResponse.status)\n                             .catch(err => console.error(`Error recording key error ${geminiResponse.status} for key ${selectedKey.id} in background:`, err));\n                    }\n\n                    // Retry all errors if not the last attempt\n                    if (attempt < MAX_RETRIES) {\n                        console.warn(`Attempt ${attempt}: Received ${geminiResponse.status} error, trying next key...`);\n                        if (useKeepAlive && keepAliveCallback) {\n                            console.log(`KEEPALIVE: Continuing heartbeat during retry attempt ${attempt + 1}`);\n                        }\n                        continue; // Go to the next iteration of the loop\n                    } else {\n                        console.error(`Attempt ${attempt}: Received ${geminiResponse.status} error, but max retries (${MAX_RETRIES}) reached.`);\n                        // Fall through to return the last recorded error after the loop\n                    }\n                } else {\n                    // 6. Process Successful Response\n                    console.log(`Attempt ${attempt}: Request successful with key ${selectedKey.id}.`);\n                    // Increment usage count for the actual model ID, not the -search version\n                    geminiKeyService.incrementKeyUsage(selectedKey.id, actualModelId, modelCategory)\n                          .catch(err => console.error(`Error incrementing usage for key ${selectedKey.id} in background:`, err));\n\n                    // For non-KEEPALIVE mode (正常流式)，不要提前消费 response.body，直接返回\n                    console.log(`Chat completions call completed successfully.`);\n                    return {\n                        response: geminiResponse,\n                        selectedKeyId: selectedKey.id,\n                        modelCategory: modelCategory\n                    };\n                }\n\n            } catch (fetchError) {\n                 // Catch network errors or other errors during fetch/key selection within an attempt\n                 console.error(`Attempt ${attempt}: Error during proxy call:`, fetchError);\n                 lastError = { message: `Internal Proxy Error during attempt ${attempt}: ${fetchError.message}`, type: 'proxy_internal_error' };\n                 lastErrorStatus = 500;\n                 // If a network error occurs, break the loop, don't retry immediately\n                 break;\n            }\n        } // --- End Retry Loop ---\n\n        // If the loop finished without returning a success or a specific non-retryable error,\n        // it means all retries resulted in 429 or we broke due to an error. Return the last recorded error.\n\n        // Stop keepalive heartbeat before returning error\n        if (useKeepAlive && keepAliveCallback) {\n            console.log('KEEPALIVE: Stopping heartbeat due to all attempts failed');\n            keepAliveCallback.stopHeartbeat();\n        }\n\n        console.error(`All ${MAX_RETRIES} attempts failed. Returning last recorded error (Status: ${lastErrorStatus}).`);\n        return { error: lastError, status: lastErrorStatus };\n\n\n    } catch (initialError) {\n         // Catch errors happening *before* the loop starts (e.g., getting initial config)\n        console.error(\"Error before starting proxy attempts:\", initialError);\n        return {\n            error: {\n                message: `Internal Proxy Error: ${initialError.message}`,\n                type: 'proxy_internal_error'\n            },\n            status: 500\n        };\n    }\n}\n\nmodule.exports = {\n    proxyChatCompletions,\n    // getProxyPoolStatus is no longer needed here, it's in proxyPool.js\n};\n"
  },
  {
    "path": "src/services/schedulerService.js",
    "content": "const cron = require('node-cron');\nconst configService = require('./configService');\nconst batchTestService = require('./batchTestService');\n\nclass SchedulerService {\n    constructor() {\n        this.batchTestTask = null;\n        this.isInitialized = false;\n    }\n\n    /**\n     * Initialize the scheduler service\n     */\n    async initialize() {\n        if (this.isInitialized) {\n            return;\n        }\n\n        console.log('Initializing Scheduler Service...');\n        \n        // Check if auto test is enabled and start the task if needed\n        await this.updateBatchTestSchedule();\n        \n        this.isInitialized = true;\n        console.log('Scheduler Service initialized.');\n    }\n\n    /**\n     * Update the batch test schedule based on current settings\n     */\n    async updateBatchTestSchedule() {\n        try {\n            // Get current auto test setting\n            const autoTestEnabled = await configService.getSetting('auto_test', '0');\n            const isEnabled = autoTestEnabled === '1' || autoTestEnabled === 1 || autoTestEnabled === true;\n\n            if (isEnabled) {\n                await this.startBatchTestSchedule();\n            } else {\n                await this.stopBatchTestSchedule();\n            }\n        } catch (error) {\n            console.error('Error updating batch test schedule:', error);\n        }\n    }\n\n    /**\n     * Start the batch test schedule (daily at 4 AM Beijing time)\n     */\n    async startBatchTestSchedule() {\n        // Stop existing task if running\n        if (this.batchTestTask) {\n            this.batchTestTask.stop();\n            this.batchTestTask = null;\n        }\n\n        // Create new cron task for 4 AM Beijing time (UTC+8)\n        // This translates to 20:00 UTC (4 AM Beijing = 4 AM UTC+8 = 20:00 UTC)\n        // Cron format: second minute hour day month dayOfWeek\n        // '0 0 20 * * *' means every day at 20:00 UTC\n        this.batchTestTask = cron.schedule('0 0 20 * * *', async () => {\n            console.log('Starting scheduled batch test at 4 AM Beijing time...');\n            try {\n                const result = await batchTestService.runBatchTest();\n                console.log('Scheduled batch test completed:', {\n                    totalKeys: result.totalKeys,\n                    successCount: result.successCount,\n                    failureCount: result.failureCount,\n                    timestamp: new Date().toISOString()\n                });\n            } catch (error) {\n                console.error('Error during scheduled batch test:', error);\n            }\n        }, {\n            scheduled: true,\n            timezone: 'UTC' // We calculate the UTC time manually for Beijing time\n        });\n\n        console.log('Batch test scheduled to run daily at 4 AM Beijing time (20:00 UTC)');\n    }\n\n    /**\n     * Stop the batch test schedule\n     */\n    async stopBatchTestSchedule() {\n        if (this.batchTestTask) {\n            this.batchTestTask.stop();\n            this.batchTestTask = null;\n            console.log('Batch test schedule stopped.');\n        }\n    }\n\n    /**\n     * Get the current status of the scheduler\n     */\n    getStatus() {\n        return {\n            isInitialized: this.isInitialized,\n            batchTestScheduled: !!this.batchTestTask,\n            nextBatchTestRun: this.batchTestTask ? 'Daily at 4 AM Beijing time' : 'Not scheduled'\n        };\n    }\n\n    /**\n     * Manually trigger a batch test (for testing purposes)\n     */\n    async triggerBatchTest() {\n        console.log('Manually triggering batch test...');\n        try {\n            const result = await batchTestService.runBatchTest();\n            console.log('Manual batch test completed:', {\n                totalKeys: result.totalKeys,\n                successCount: result.successCount,\n                failureCount: result.failureCount,\n                timestamp: new Date().toISOString()\n            });\n            return result;\n        } catch (error) {\n            console.error('Error during manual batch test:', error);\n            throw error;\n        }\n    }\n\n    /**\n     * Shutdown the scheduler service\n     */\n    async shutdown() {\n        console.log('Shutting down Scheduler Service...');\n        \n        if (this.batchTestTask) {\n            this.batchTestTask.stop();\n            this.batchTestTask = null;\n        }\n        \n        this.isInitialized = false;\n        console.log('Scheduler Service shut down.');\n    }\n}\n\n// Create singleton instance\nconst schedulerService = new SchedulerService();\n\nmodule.exports = schedulerService;\n"
  },
  {
    "path": "src/services/vertexProxyService.js",
    "content": "const fetch = require('node-fetch');\nconst { Readable, Transform } = require('stream'); // Import Transform\nconst fs = require('fs').promises; // Async fs for temp file operations\nconst os = require('os');\nconst path = require('path');\nconst { v4: uuidv4 } = require('uuid');\nconst { GoogleGenAI } = require('@google/genai');\nconst configService = require('./configService');\nconst transformUtils = require('../utils/transform');\n\n// List of Vertex AI supported models (prefix [v] indicates it's a Vertex API model)\nconst VERTEX_SUPPORTED_MODELS = [\n    \"[v]gemini-2.5-flash\",\n    \"[v]gemini-2.5-pro\"\n];\n\n// Default region\nconst DEFAULT_REGION = 'us-central1';\n\n// Temporary credentials file path\nlet tempCredentialsPath = null;\n\n// --- Database-only Configuration ---\nlet VERTEX_JSON_STRING = null; // Store database loaded value\n\n// --- Initialize Credentials on Load ---\nlet isVertexInitialized = false;\nlet isUsingExpressMode = false; // Track if we're using Express Mode\n\n/**\n * Initializes Vertex credentials (loads JSON, creates temp file, sets env var) on service start.\n */\nasync function initializeVertexCredentials() {\n    if (isVertexInitialized) return; // Already initialized\n\n    // First try to load configuration from database (priority)\n    let databaseConfig = null;\n    let databaseError = false;\n    try {\n        databaseConfig = await configService.getSetting('vertex_config', null);\n    } catch (error) {\n        console.warn(\"Failed to load Vertex config from database:\", error);\n        databaseError = true;\n    }\n\n    // If database is not available, disable Vertex AI\n    if (databaseError) {\n        console.info(\"Database not available for Vertex configuration, Vertex AI disabled\");\n        isVertexInitialized = true; // Mark as initialized (but disabled)\n        return;\n    }\n\n    // Check database configuration first\n    if (databaseConfig && (databaseConfig.expressApiKey || databaseConfig.vertexJson)) {\n        console.info(\"Using Vertex AI configuration from database\");\n\n        if (databaseConfig.expressApiKey) {\n            // Use Express Mode from database\n            console.info(\"Using Vertex AI Express Mode with API key from database\");\n            isUsingExpressMode = true;\n            isVertexInitialized = true;\n            return;\n        } else if (databaseConfig.vertexJson) {\n            // Use Service Account from database\n            try {\n                JSON.parse(databaseConfig.vertexJson); // Validate JSON\n                VERTEX_JSON_STRING = databaseConfig.vertexJson;\n                console.info(\"Using VERTEX credentials from database\");\n\n                const createdPath = await createServiceAccountFile(VERTEX_JSON_STRING);\n                if (!createdPath) {\n                    throw new Error(\"createServiceAccountFile returned null or undefined.\");\n                }\n                tempCredentialsPath = createdPath;\n                process.env.GOOGLE_APPLICATION_CREDENTIALS = tempCredentialsPath;\n                console.log(`Vertex AI credentials created and set from database: GOOGLE_APPLICATION_CREDENTIALS=${tempCredentialsPath}`);\n                isVertexInitialized = true;\n                return;\n            } catch (error) {\n                console.error(\"Failed to initialize Vertex AI credentials from database:\", error);\n                console.info(\"Database Vertex configuration is invalid, Vertex AI disabled\");\n                isVertexInitialized = true; // Mark as initialized (but disabled)\n                return;\n            }\n        }\n    } else if (databaseConfig === null) {\n        // Database configuration is explicitly null (not found)\n        // According to user requirements: when database has no vertex config, should not enable vertex\n        console.info(\"No Vertex AI configuration found in database, Vertex AI disabled\");\n        isVertexInitialized = true; // Mark as initialized (but disabled)\n        return;\n    } else {\n        // Database configuration exists but is invalid\n        console.warn(\"Invalid Vertex AI configuration in database, Vertex AI disabled\");\n        isVertexInitialized = true; // Mark as initialized (but disabled)\n        return;\n    }\n\n    // This should not be reached - all cases should return above\n    console.error(\"Unexpected code path in Vertex AI initialization\");\n    isVertexInitialized = true; // Mark as initialized (but disabled)\n    return;\n}\n\n// Don't initialize immediately when module loads - will be initialized after DB is ready\n// initializeVertexCredentials();\n\n\n/**\n * Creates a temporary service account file for Vertex AI authentication.\n * @param {string} vertexJsonString - JSON string containing the service account credentials.\n * @returns {Promise<string|null>} The path to the temporary file, or null on failure.\n */\nasync function createServiceAccountFile(vertexJsonString) {\n    try {\n        const serviceAccountInfo = JSON.parse(vertexJsonString);\n\n        // Basic validation\n        const requiredKeys = [\"type\", \"project_id\", \"private_key_id\", \"private_key\", \"client_email\", \"client_id\"];\n        if (!requiredKeys.every(key => key in serviceAccountInfo)) {\n            console.error(\"Invalid JSON format for Vertex database configuration. Missing required keys.\");\n            return null;\n        }\n        if (serviceAccountInfo.type !== \"service_account\") {\n            console.error(\"Invalid JSON format for Vertex database configuration. 'type' must be 'service_account'.\");\n            return null;\n        }\n\n        const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'vertexai-nodejs-'));\n        const tempFilePath = path.join(tempDir, 'service-account.json');\n        await fs.writeFile(tempFilePath, JSON.stringify(serviceAccountInfo, null, 2), 'utf-8');\n        // console.info(`Successfully parsed 'VERTEX' JSON and created temporary credentials file: ${tempFilePath}`); // Removed log\n        return tempFilePath;\n    } catch (e) {\n        console.error(`Failed to create service account file from Vertex database configuration JSON: ${e}`, e);\n        return null;\n    }\n}\n\n/**\n * Maps OpenAI roles to Vertex AI roles.\n * @param {string} openaiRole - The role from the OpenAI request ('system', 'user', 'assistant', 'tool').\n * @returns {string} The corresponding Vertex AI role ('user', 'model', 'function').\n */\nfunction mapOpenaiRoleToVertex(openaiRole) {\n    const roleMap = {\n        system: 'user', // Treat system messages as user messages for compatibility\n        user: 'user',\n        assistant: 'model',\n        tool: 'function' // Tool results map to 'function' role in Vertex\n    };\n    return roleMap[openaiRole.toLowerCase()] || 'user'; // Default to user\n}\n\n/**\n * Parses a data URI (e.g., for base64 encoded images).\n * @param {string} uri - The data URI string.\n * @returns {{mimeType: string, data: Buffer}|null} Parsed mime type and data buffer, or null if invalid.\n */\nfunction parseImageDataUri(uri) {\n    if (!uri || !uri.startsWith('data:')) {\n        return null;\n    }\n    try {\n        const commaIndex = uri.indexOf(',');\n        if (commaIndex === -1) return null;\n\n        const header = uri.substring(5, commaIndex); // Remove 'data:' prefix\n        const encodedData = uri.substring(commaIndex + 1);\n        const parts = header.split(';');\n        const mimeType = parts[0];\n\n        if (parts.includes('base64')) {\n            const data = Buffer.from(encodedData, 'base64');\n            return { mimeType, data };\n        } else {\n            // Handle other encodings (e.g., URL encoding)\n            console.warn(`Unsupported data URI encoding (non-base64): ${parts.slice(1).join(';')}`); // Keep warn log in English\n            return { mimeType, data: Buffer.from(decodeURIComponent(encodedData)) }; // Attempt URL decoding\n        }\n    } catch (e) {\n        console.error(`Error parsing data URI: ${e}`, e); // Keep error log in English\n        return null;\n    }\n}\n\n/**\n * Asynchronously converts OpenAI message content parts to Vertex AI Parts, handling text and images.\n * Downloads images from HTTPS URLs if necessary.\n * @param {Array<object>} openAIContentParts - Array of OpenAI content parts (text or image_url).\n * @returns {Promise<Array<object>>} A promise resolving to an array of Vertex AI Part objects.\n */\nasync function convertOpenaiPartsToVertexParts(openAIContentParts) {\n    const vertexParts = [];\n    for (const part of openAIContentParts) {\n        if (part.type === 'text') {\n            vertexParts.push({ text: part.text });\n        } else if (part.type === 'image_url' && part.image_url) {\n            const imageUrl = part.image_url.url;\n            if (imageUrl.startsWith('data:')) {\n                const parsed = parseImageDataUri(imageUrl);\n                if (parsed) {\n                    vertexParts.push({\n                        inlineData: {\n                            mimeType: parsed.mimeType,\n                            data: parsed.data.toString('base64') // Vertex SDK expects base64 string\n                        }\n                    });\n                } else {\n                    console.warn(`Could not parse data URI: ${imageUrl.substring(0, 50)}...`); // Keep warn log in English\n                    vertexParts.push({ text: `[Failed to parse image data URI]` });\n                }\n            } else if (imageUrl.startsWith('gs://')) {\n                // Handle Google Cloud Storage URIs\n                const mime = require('mime-types'); // Lazy require mime-types\n                vertexParts.push({\n                    fileData: {\n                        mimeType: mime.lookup(imageUrl) || 'application/octet-stream',\n                        fileUri: imageUrl\n                    }\n                });\n            } else if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {\n                // Attempt to download image from URL\n                try {\n                    const response = await fetch(imageUrl, { timeout: 10000 }); // 10s timeout\n                    if (!response.ok) {\n                        throw new Error(`HTTP error! status: ${response.status}`);\n                    }\n                    const imageBuffer = await response.buffer();\n                    const contentType = response.headers.get('content-type') || 'application/octet-stream';\n                    vertexParts.push({\n                        inlineData: {\n                            mimeType: contentType,\n                            data: imageBuffer.toString('base64')\n                        }\n                    });\n                } catch (e) {\n                    console.error(`Failed to download image from ${imageUrl}: ${e}`); // Keep error log in English\n                    vertexParts.push({ text: `[Failed to load image at ${imageUrl}]` });\n                }\n            } else {\n                console.warn(`Unsupported image URL format: ${imageUrl}`); // Keep warn log in English\n                vertexParts.push({ text: `[Unsupported image format at ${imageUrl}]` });\n            }\n        }\n    }\n    return vertexParts;\n}\n\n/**\n * Converts OpenAI message list to Vertex AI Content array.\n * @param {Array<object>} messages - Array of OpenAI message objects.\n * @returns {Promise<Array<object>>} A promise resolving to an array of Vertex AI Content objects.\n */\nasync function convertOpenaiMessagesToVertex(messages) {\n    const vertexContents = [];\n    \n    // Process all messages, including system messages, mapping to appropriate Vertex roles\n    for (const msg of messages) {\n        const vertexRole = mapOpenaiRoleToVertex(msg.role);\n        let parts = [];\n\n        if (vertexRole === 'function') { // Handle tool/function results\n            if (msg.tool_call_id && msg.content) {\n                if (msg.name) {\n                    let responseContent = {};\n                    try {\n                        // Attempt to parse the string content into an object\n                        responseContent = JSON.parse(msg.content);\n                    } catch (e) {\n                        console.warn(`Tool result content for ${msg.name} (${msg.tool_call_id}) is not valid JSON, sending as string: ${msg.content}`); // Keep warn log in English\n                        // Send as simple text if not parsable JSON\n                        parts.push({ text: `[Tool Result for ${msg.name}: ${msg.content}]` });\n                        continue; // Skip adding as functionResponse if invalid\n                    }\n                    parts.push({\n                        functionResponse: {\n                            name: msg.name,\n                            response: responseContent // Vertex SDK expects the actual object\n                        }\n                    });\n                } else {\n                    console.warn(`Tool message received without function name (expected in msg.name): ${JSON.stringify(msg)}`); // Keep warn log in English\n                    parts.push({ text: `[Tool Result for ${msg.tool_call_id}: ${msg.content}]` });\n                }\n            } else {\n                console.warn(`Tool message missing tool_call_id or content: ${JSON.stringify(msg)}`); // Keep warn log in English\n                parts.push({ text: msg.content || '[Empty Tool Message]' });\n            }\n        } else if (vertexRole === 'model') { // Handle assistant messages (including potential tool calls)\n            if (msg.tool_calls && msg.tool_calls.length > 0) {\n                // If assistant message contains tool calls, represent them as FunctionCallParts\n                for (const toolCall of msg.tool_calls) {\n                    if (toolCall.type === 'function' && toolCall.function) {\n                        let args = {};\n                        try {\n                            // Arguments from OpenAI are a JSON string, Vertex expects an object\n                            args = JSON.parse(toolCall.function.arguments || '{}');\n                        } catch (e) {\n                            console.error(`Failed to parse tool call arguments for ${toolCall.function.name}: ${e}`); // Keep error log in English\n                            args = { _error: \"Failed to parse arguments\", raw_arguments: toolCall.function.arguments };\n                        }\n                        parts.push({\n                            functionCall: {\n                                name: toolCall.function.name,\n                                args: args // Pass the parsed object\n                            }\n                        });\n                    }\n                }\n                // If there's also text content along with tool calls, add it as a separate text part\n                if (msg.content && typeof msg.content === 'string') {\n                    parts.push({ text: msg.content });\n                } else if (Array.isArray(msg.content)) {\n                    // Handle multi-part assistant messages (rare but possible)\n                    const textParts = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\\n');\n                    if (textParts) {\n                        parts.push({ text: textParts });\n                    }\n                    // Note: Image parts from assistant messages are generally not expected/handled here.\n                }\n            } else {\n                // Normal assistant message (text or potentially multimodal)\n                if (typeof msg.content === 'string') {\n                    parts.push({ text: msg.content });\n                } else if (Array.isArray(msg.content)) {\n                    parts = parts.concat(await convertOpenaiPartsToVertexParts(msg.content));\n                }\n            }\n        } else { // Handle 'user' messages (can be text or multimodal)\n            if (typeof msg.content === 'string') {\n                parts.push({ text: msg.content });\n            } else if (Array.isArray(msg.content)) {\n                parts = parts.concat(await convertOpenaiPartsToVertexParts(msg.content));\n            }\n        }\n\n        if (parts.length > 0) {\n            // Ensure role mapping is correct before pushing\n            const finalVertexRole = mapOpenaiRoleToVertex(msg.role);\n            vertexContents.push({ role: finalVertexRole, parts });\n        } else {\n            console.warn(`Message resulted in empty parts, skipping: ${JSON.stringify(msg)}`); // Keep warn log in English\n        }\n    }\n\n    return vertexContents;\n}\n\n/**\n * Converts OpenAI tool definitions to Vertex AI Tool format.\n * @param {Array<object>|null} tools - Array of OpenAI tool objects.\n * @returns {Array<object>|null} Array of Vertex AI Tool objects or null.\n */\nfunction convertOpenaiToolsToVertex(tools) {\n    if (!tools || tools.length === 0) {\n        return null;\n    }\n\n    const functionDeclarations = [];\n    for (const tool of tools) {\n        if (tool.type === 'function' && tool.function) {\n            const func = tool.function;\n            functionDeclarations.push({\n                name: func.name,\n                description: func.description || '',\n                // Pass the parameters object directly, assuming it's compatible enough for the SDK\n                parameters: func.parameters || { type: 'object', properties: {} } // Provide default empty schema if none\n            });\n        } else {\n            console.warn(`Unsupported tool type encountered: ${tool.type}`); // Keep warn log in English\n        }\n    }\n\n    if (functionDeclarations.length > 0) {\n        // Vertex SDK expects a Tool object containing the declarations\n        return [{ functionDeclarations }];\n    }\n\n    return null;\n}\n\n/**\n * Maps Vertex AI finish reasons to OpenAI finish reasons.\n * @param {string|null} reason - Vertex AI finish reason string.\n * @returns {string|null} OpenAI finish reason string or null.\n */\nfunction convertVertexFinishReasonToOpenai(reason) {\n    if (!reason) return null;\n    const mapping = {\n        'STOP': 'stop',\n        'MAX_TOKENS': 'length',\n        'SAFETY': 'content_filter',\n        'RECITATION': 'content_filter', // Often related to safety/policy\n        'TOOL_CALL': 'tool_calls',\n        'FUNCTION_CALL': 'tool_calls', // Older naming\n        'FINISH_REASON_UNSPECIFIED': null,\n        'OTHER': null\n    };\n    return mapping[reason.toUpperCase()] || null; // Default to null if unknown\n}\n\n/**\n * Converts a Vertex FunctionCallPart or FunctionCall object into an OpenAI tool_calls object.\n * @param {object} functionCall - The Vertex functionCall object.\n * @param {number} [index=0] - Optional index for multiple tool calls.\n * @returns {object|null} OpenAI tool_calls object structure, or null if input is invalid.\n */\nfunction convertVertexToolCallToOpenai(functionCall, index = 0) {\n    if (!functionCall || !functionCall.name) {\n        console.error(\"Invalid functionCall object received from Vertex\", functionCall); // Keep error log in English\n        return null;\n    }\n    return {\n        id: `call_${uuidv4()}`, // Generate a unique ID for the call\n        type: 'function',\n        function: {\n            name: functionCall.name,\n            // OpenAI expects arguments as a JSON string\n            arguments: JSON.stringify(functionCall.args || {})\n        },\n        index: index\n    };\n}\n\n/**\n * Creates Vertex AI safety settings.\n * @param {string} [blockLevel='OFF'] - The threshold level.\n * @returns {Array<object>} Array of Vertex safety setting objects.\n */\nfunction createSafetySettings(blockLevel = 'OFF') {\n    const HarmCategory = {\n        HARM_CATEGORY_UNSPECIFIED: \"HARM_CATEGORY_UNSPECIFIED\",\n        HARM_CATEGORY_HATE_SPEECH: \"HARM_CATEGORY_HATE_SPEECH\",\n        HARM_CATEGORY_DANGEROUS_CONTENT: \"HARM_CATEGORY_DANGEROUS_CONTENT\",\n        HARM_CATEGORY_HARASSMENT: \"HARM_CATEGORY_HARASSMENT\",\n        HARM_CATEGORY_SEXUALLY_EXPLICIT: \"HARM_CATEGORY_SEXUALLY_EXPLICIT\"\n    };\n\n    const categories = [\n        HarmCategory.HARM_CATEGORY_HATE_SPEECH,\n        HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,\n        HarmCategory.HARM_CATEGORY_HARASSMENT,\n        HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT\n    ];\n    return categories.map(category => ({\n        category: category,\n        threshold: blockLevel // Use the string representation\n    }));\n}\n\n/**\n * Handles chat completion requests for the Vertex API.\n */\nasync function proxyVertexChatCompletions(openAIRequestBody, workerApiKey, stream, keepAliveCallback = null) {\n    console.log(\"Using Vertex AI proxy service\"); // Keep log in English\n\n    // Whether to use KEEPALIVE in streaming mode - get from database only\n    const keepAliveEnabled = String(await configService.getSetting('keepalive', '0')) === '1';\n    const requestedModelId = openAIRequestBody?.model;\n\n    // Validate request\n    if (!requestedModelId) {\n        return { error: { message: \"Missing 'model' field in request body\" }, status: 400 };\n    }\n    if (!openAIRequestBody.messages || !Array.isArray(openAIRequestBody.messages)) {\n        return { error: { message: \"Missing or invalid 'messages' field in request body\" }, status: 400 };\n    }\n\n    // Remove [v] prefix from model name to get the actual Vertex model ID\n    let vertexModelId = requestedModelId;\n    if (vertexModelId.startsWith('[v]')) {\n        vertexModelId = vertexModelId.substring(3);\n    }\n\n    // Check safety setting\n    let isSafetyEnabled;\n    try {\n        isSafetyEnabled = await configService.getWorkerKeySafetySetting(workerApiKey);\n    } catch (error) {\n        console.error(\"Error getting worker key safety setting:\", error); // Keep error log in English\n        isSafetyEnabled = true; // Default to enabled safety settings\n    }\n\n    // Check initialization status\n    if (!isVertexInitialized) {\n        return {\n            error: {\n                message: \"Vertex AI service not initialized.\"\n            },\n            status: 500\n        };\n    }\n\n    let ai;\n    \n    try {\n        // Initialize client based on authentication mode\n        if (isUsingExpressMode) {\n            // Express Mode with API Key - get from database only\n            let expressApiKey = null;\n\n            try {\n                const databaseConfig = await configService.getSetting('vertex_config', null);\n                if (databaseConfig && databaseConfig.expressApiKey) {\n                    expressApiKey = databaseConfig.expressApiKey;\n                    console.log(\"Using Express API Key from database\");\n                } else {\n                    throw new Error(\"Express API Key not found in database configuration\");\n                }\n            } catch (error) {\n                console.error(\"Failed to load Express API Key from database:\", error);\n                throw new Error(\"EXPRESS_API_KEY is not available in database configuration.\");\n            }\n\n            if (!expressApiKey) {\n                throw new Error(\"EXPRESS_API_KEY is not available in database configuration.\");\n            }\n\n            ai = new GoogleGenAI({\n                vertexai: true,\n                apiKey: expressApiKey\n            });\n\n            console.log(\"Vertex AI Client initialized with Express Mode API Key\"); // Keep log in English\n        } else {\n            // Standard mode with service account\n            if (!tempCredentialsPath) {\n                return {\n                    error: {\n                        message: \"Temporary credentials path not set for service account authentication.\"\n                    },\n                    status: 500\n                };\n            }\n            \n            // Read service account file to get project_id\n            const keyFileContent = await fs.readFile(tempCredentialsPath, 'utf-8');\n            const keyFileData = JSON.parse(keyFileContent);\n            const project_id = keyFileData.project_id;\n            \n            if (!project_id) {\n                throw new Error(\"No project_id found in service account JSON\");\n            }\n\n            // Initialize GoogleGenAI client with Vertex AI service account configuration\n            let region = DEFAULT_REGION;\n            ai = new GoogleGenAI({\n                vertexai: true,\n                project: project_id,\n                location: region\n            });\n            \n            console.log(`Vertex AI Client initialized for project '${project_id}' in region '${region}'.`); // Keep log in English\n        }\n\n        // Convert OpenAI format to Vertex format\n        const vertexContents = await convertOpenaiMessagesToVertex(openAIRequestBody.messages);\n        const vertexTools = convertOpenaiToolsToVertex(openAIRequestBody.tools);\n        \n        // Set safety level\n        const safetySettings = createSafetySettings(isSafetyEnabled ? 'BLOCK_MEDIUM_AND_ABOVE' : 'OFF');\n\n        // Configure generation parameters\n        const generationConfig = {\n            maxOutputTokens: openAIRequestBody.max_tokens,\n            temperature: openAIRequestBody.temperature,\n            topP: openAIRequestBody.top_p,\n            topK: openAIRequestBody.top_k,\n            stopSequences: typeof openAIRequestBody.stop === 'string' ? [openAIRequestBody.stop] : openAIRequestBody.stop\n        };\n        \n        // Remove undefined keys\n        Object.keys(generationConfig).forEach(key => \n            generationConfig[key] === undefined && delete generationConfig[key]\n        );\n\n        // Tool configuration\n        let toolConfig = null;\n        if (vertexTools) {\n            let mode = 'AUTO'; // Default\n            let allowedFunctionNames = [];\n\n            if (openAIRequestBody.tool_choice) {\n                if (typeof openAIRequestBody.tool_choice === 'string') {\n                    if (openAIRequestBody.tool_choice === 'none') {\n                        mode = 'NONE';\n                    } else if (openAIRequestBody.tool_choice === 'auto') {\n                        mode = 'AUTO';\n                    } else {\n                        mode = 'ANY'; // Assume non-standard string is a function name\n                        allowedFunctionNames.push(openAIRequestBody.tool_choice);\n                    }\n                } else if (typeof openAIRequestBody.tool_choice === 'object' && openAIRequestBody.tool_choice.type === 'function') {\n                    const funcName = openAIRequestBody.tool_choice.function?.name;\n                    if (funcName) {\n                        mode = 'ANY'; // ANY requires specifying function name(s)\n                        allowedFunctionNames.push(funcName);\n                    }\n                }\n            }\n            toolConfig = {\n                functionCallingConfig: {\n                    mode: mode,\n                    allowedFunctionNames: allowedFunctionNames.length > 0 ? allowedFunctionNames : undefined\n                }\n            };\n        }\n\n        // Build the request payload with all parameters\n        const requestPayload = {\n            model: vertexModelId,\n            contents: vertexContents,\n            generationConfig: generationConfig,\n            safetySettings: safetySettings,\n            tools: vertexTools,\n            toolConfig: toolConfig\n        };\n        // Remove keys with null or undefined values from the payload\n        Object.keys(requestPayload).forEach(key => (requestPayload[key] == null) && delete requestPayload[key]);\n\n\n        // Determine if KEEPALIVE mode should be used:\n        // 1. KEEPALIVE environment variable is set to 1\n        // 2. Client requested streaming\n        // 3. Safety settings are disabled\n        const useKeepAlive = keepAliveEnabled && stream && !isSafetyEnabled;\n        \n        // Determine the actual stream mode based on whether KEEPALIVE is used\n        const actualStreamMode = useKeepAlive ? false : stream;\n        \n        // Log KEEPALIVE mode\n        if (useKeepAlive) {\n            console.log(`Using KEEPALIVE mode: Client requested streaming but sending non-streaming request to Vertex (safety settings disabled)`); // Keep log in English\n        }\n\n        // Handle response\n        if (stream) {\n            if (useKeepAlive) {\n                // KEEPALIVE mode: Asynchronous handling with internal retry logic\n                if (keepAliveCallback) {\n                    const keepAliveRunner = async () => {\n                        console.log('KEEPALIVE (Vertex): Starting heartbeat and async request process.');\n                        keepAliveCallback.startHeartbeat();\n\n                        let lastKeepAliveError = null;\n                        const MAX_RETRIES = parseInt(await configService.getSetting('max_retry', '1')) || 1;\n\n                        for (let kAttempt = 1; kAttempt <= MAX_RETRIES; kAttempt++) {\n                            try {\n                                console.log(`KEEPALIVE (Vertex) Attempt ${kAttempt}: Sending request.`);\n                                const response = await ai.models.generateContent(requestPayload);\n\n                                // Check for valid response\n                                if (!response || !response.candidates || response.candidates.length === 0) {\n                                     const promptFeedback = response?.promptFeedback;\n                                     if (promptFeedback?.blockReason) {\n                                         const blockMessage = promptFeedback.blockReasonMessage || `Blocked due to ${promptFeedback.blockReason}`;\n                                         throw new Error(JSON.stringify({\n                                             error: {\n                                                 message: `Request blocked by Vertex AI safety filters: ${blockMessage}`,\n                                                 type: \"vertex_ai_safety_filter\",\n                                                 code: \"content_filter\"\n                                             }\n                                         }));\n                                     }\n                                     throw new Error(\"No valid candidates received from Vertex AI.\");\n                                }\n\n                                // Success case\n                                console.log(`KEEPALIVE (Vertex): Request successful on attempt ${kAttempt}. Stopping heartbeat.`);\n                                keepAliveCallback.stopHeartbeat();\n                                keepAliveCallback.sendFinalResponse(response);\n                                return; // Exit runner on success\n\n                            } catch (error) {\n                                console.error(`KEEPALIVE (Vertex) Attempt ${kAttempt} failed:`, error.message);\n                                try {\n                                   // Try to parse the error message as it might be a JSON string\n                                   lastKeepAliveError = JSON.parse(error.message).error || { message: error.message };\n                                } catch (e) {\n                                   // If parsing fails, use the raw message\n                                   lastKeepAliveError = { message: error.message };\n                                }\n                                \n                                if (kAttempt < MAX_RETRIES) {\n                                    console.warn(`KEEPALIVE (Vertex): Retrying...`);\n                                }\n                            }\n                        }\n\n                        // If loop finishes, all retries have failed\n                        console.error(`KEEPALIVE (Vertex): All ${MAX_RETRIES} attempts failed. Sending last error.`);\n                        keepAliveCallback.stopHeartbeat();\n                        keepAliveCallback.sendError(lastKeepAliveError || { message: \"All Vertex keepalive attempts failed.\" });\n                    };\n\n                    keepAliveRunner(); // Run the async function\n\n                    // Return immediately to the client\n                    return {\n                        isKeepAlive: true,\n                        selectedKeyId: 'vertex-ai',\n                        modelCategory: 'Vertex',\n                        requestedModelId: requestedModelId\n                    };\n                } else {\n                     console.error('KEEPALIVE: No callback available for Vertex KEEPALIVE mode');\n                     return {\n                         error: {\n                             message: 'KEEPALIVE callback not available for Vertex',\n                             type: 'vertex_keepalive_setup_error'\n                         },\n                         status: 500\n                     };\n                }\n            } else {\n                // Standard streaming mode\n                try {\n                    // Use the new API for streaming\n                    const streamResult = await ai.models.generateContentStream(requestPayload);\n                    \n                    let toolCallIndex = 0; // Keep track across chunks\n\n                    // Create a Transform stream to process the stream from Vertex SDK\n                    const vertexTransformer = new Transform({\n                        objectMode: true, // Process objects from Vertex SDK\n                        async transform(item, encoding, callback) {\n                            try {\n                                if (!item || !item.candidates || item.candidates.length === 0) {\n                                    return callback(); // Skip empty items\n                                }\n\n                                const candidate = item.candidates[0];\n                                const finishReasonVertex = candidate?.finishReason;\n                                const finishReasonOpenai = convertVertexFinishReasonToOpenai(finishReasonVertex);\n\n                                let deltaContent = null;\n                                let deltaToolCalls = [];\n\n                                if (candidate.content && candidate.content.parts) {\n                                    for (const part of candidate.content.parts) {\n                                        if (part.text) {\n                                            deltaContent = part.text;\n                                        } else if (part.functionCall) {\n                                            const openaiToolCall = convertVertexToolCallToOpenai(part.functionCall, toolCallIndex++);\n                                            if (openaiToolCall) {\n                                                deltaToolCalls.push(openaiToolCall);\n                                            }\n                                        }\n                                    }\n                                }\n\n                                // Create chunk only if there's content, tool calls, or a finish reason\n                                if (deltaContent !== null || deltaToolCalls.length > 0 || finishReasonOpenai) {\n                                    const choiceDelta = {\n                                        role: 'assistant',\n                                        content: deltaContent,\n                                        tool_calls: deltaToolCalls.length > 0 ? deltaToolCalls : undefined\n                                    };\n                                    const streamChoice = {\n                                        index: 0,\n                                        delta: choiceDelta,\n                                        finish_reason: finishReasonOpenai,\n                                        logprobs: null\n                                    };\n                                    const responseChunk = {\n                                        id: `chatcmpl-stream-${uuidv4()}`,\n                                        object: 'chat.completion.chunk',\n                                        created: Math.floor(Date.now() / 1000),\n                                        model: requestedModelId,\n                                        choices: [streamChoice],\n                                        usage: null\n                                    };\n                                    // Push the transformed JSON string downstream\n                                    this.push(JSON.stringify(responseChunk));\n                                }\n                                \n                                // Prepare to end if there's a finish reason\n                                // Note: No need to explicitly end the stream here, let the source stream end naturally\n                                callback();\n                            } catch (err) {\n                                callback(err); // Propagate errors\n                            }\n                        },\n\n                        flush(callback) {\n                            // Getting final aggregated usage data is difficult here as we are a transform stream\n                            // Ignore sending aggregated data for now\n                            \n                            // Send the [DONE] message\n                            this.push(JSON.stringify({ done: true }));\n                            callback();\n                        }\n                    });\n\n                    // Pipe the Vertex SDK stream through our transformer\n                    // The streamResult might be structured differently based on the API mode\n                    // In standard mode: streamResult.stream is AsyncIterable<GenerateContentResponse>\n                    // In Express Mode: streamResult itself might be the iterable\n                    let sdkStream;\n                    \n                    if (streamResult.stream) {\n                        // Standard mode structure with .stream property\n                        sdkStream = Readable.from(streamResult.stream);\n                    } else if (streamResult[Symbol.asyncIterator] || streamResult[Symbol.iterator]) {\n                        // Express Mode might return the iterator directly\n                        sdkStream = Readable.from(streamResult);\n                    } else {\n                        throw new Error(\"Unexpected response format from Vertex AI streaming API\");\n                    }\n                    \n                    const outputStream = sdkStream.pipe(vertexTransformer);\n\n                    return {\n                        response: { body: outputStream }, // Return the transform stream directly\n                        selectedKeyId: 'vertex-ai',\n                        modelCategory: 'Vertex'\n                    };\n\n                } catch (error) {\n                    console.error(`Error during Vertex AI stream generation: ${error}`, error); // Keep error log in English\n                    return {\n                        error: {\n                            message: `Vertex AI stream generation failed: ${error.message}`,\n                            type: 'vertex_ai_error'\n                        },\n                        status: 500\n                    };\n                }\n            }\n        } else {\n            // Non-streaming response\n            try {\n                // Use the new API for non-streaming\n                const response = await ai.models.generateContent(requestPayload);\n\n                if (!response || !response.candidates || response.candidates.length === 0) {\n                    // Check if blocked by safety filter\n                    const promptFeedback = response?.promptFeedback;\n                    if (promptFeedback?.blockReason) {\n                        const blockMessage = promptFeedback.blockReasonMessage || `Blocked due to ${promptFeedback.blockReason}`;\n                        console.warn(`Request blocked by safety filters: ${blockMessage}`); // Keep warn log in English\n                        return {\n                            error: {\n                                message: `Request blocked by Vertex AI safety filters: ${blockMessage}`,\n                                type: \"vertex_ai_safety_filter\",\n                                code: \"content_filter\"\n                            },\n                            status: 400\n                        };\n                    }\n                    throw new Error(\"No valid candidates received from Vertex AI.\");\n                }\n\n                const candidate = response.candidates[0];\n                const finishReasonVertex = candidate.finishReason;\n                const finishReasonOpenai = convertVertexFinishReasonToOpenai(finishReasonVertex);\n\n                let responseContent = null;\n                let responseToolCalls = [];\n\n                if (candidate.content && candidate.content.parts) {\n                    const textParts = [];\n                    for (const part of candidate.content.parts) {\n                        if (part.text) {\n                            textParts.push(part.text);\n                        } else if (part.functionCall) {\n                            const openaiToolCall = convertVertexToolCallToOpenai(part.functionCall);\n                            if (openaiToolCall) {\n                                responseToolCalls.push(openaiToolCall);\n                            }\n                        }\n                    }\n                    if (textParts.length > 0) {\n                        responseContent = textParts.join(''); // Concatenate text parts\n                    }\n                }\n\n                // Handle response blocked by safety filter\n                if (finishReasonOpenai === 'content_filter' && !responseContent && responseToolCalls.length === 0) {\n                    const safetyRatings = candidate.safetyRatings || [];\n                    const blockMessages = safetyRatings.filter(r => r.blocked).map(r => `${r.category}: ${r.severity || 'Blocked'}`);\n                    const message = `Response blocked by Vertex AI safety filters. Reasons: ${blockMessages.join(' ') || finishReasonVertex}`;\n                    console.warn(message); // Keep warn log in English\n                    return {\n                        error: {\n                            message: message,\n                            type: \"vertex_ai_safety_filter\",\n                            code: \"content_filter\"\n                        },\n                        status: 400\n                    };\n                }\n\n                // Build OpenAI format response message\n                const message = {\n                    role: 'assistant',\n                    content: responseContent, // Can be null if only tool calls\n                    tool_calls: responseToolCalls.length > 0 ? responseToolCalls : undefined\n                };\n\n                const choice = {\n                    index: 0,\n                    message: message,\n                    finish_reason: finishReasonOpenai,\n                    logprobs: null // Not supported\n                };\n\n                // Extract usage statistics\n                const usage = {\n                    prompt_tokens: response.usageMetadata?.promptTokenCount || 0,\n                    completion_tokens: response.usageMetadata?.candidatesTokenCount || 0,\n                    total_tokens: response.usageMetadata?.totalTokenCount || (response.usageMetadata?.promptTokenCount || 0) + (response.usageMetadata?.candidatesTokenCount || 0) // Calculate if not present\n                };\n\n                // Create the full OpenAI format response\n                const openaiResponse = {\n                    id: `chatcmpl-${uuidv4()}`,\n                    object: 'chat.completion',\n                    created: Math.floor(Date.now() / 1000),\n                    model: requestedModelId,\n                    choices: [choice],\n                    usage: usage,\n                    system_fingerprint: null // Not provided by Vertex\n                };\n\n                // Format the response as a JSON object for the 'json' method\n                return {\n                    response: {\n                        json: () => Promise.resolve(openaiResponse), // Return the object directly\n                        ok: true,\n                        status: 200\n                    },\n                    selectedKeyId: 'vertex-ai',\n                    modelCategory: 'Vertex'\n                };\n\n            } catch (error) {\n                console.error(`Error during Vertex AI non-stream generation: ${error}`, error); // Keep error log in English\n                return {\n                    error: {\n                        message: `Vertex AI non-stream generation failed: ${error.message}`,\n                        type: 'vertex_ai_error'\n                    },\n                    status: 500\n                };\n            }\n        }\n    } catch (error) {\n        console.error(`Error in Vertex AI proxy: ${error}`, error); // Keep error log in English\n        // Clean up temporary file\n        if (tempCredentialsPath) {\n            try {\n                const dirPath = path.dirname(tempCredentialsPath);\n                await fs.rm(dirPath, { recursive: true, force: true });\n                console.info(`Cleaned up temporary credentials directory: ${dirPath}`); \n            } catch (e) {\n                console.warn(`Failed to delete temporary credentials directory: ${e}`); // Keep warn log in English\n            }\n        }\n        return {\n            error: {\n                message: `Internal Vertex AI Proxy Error: ${error.message}`,\n                type: 'vertex_internal_error'\n            },\n            status: 500\n        };\n    }\n}\n\n/**\n * Gets the list of Vertex supported models.\n * @returns {Array<string>} Array of supported model IDs.\n */\nfunction getVertexSupportedModels() {\n    // Return supported models if either authentication method is available\n    return (isUsingExpressMode || VERTEX_JSON_STRING) ? VERTEX_SUPPORTED_MODELS : [];\n}\n\n/**\n * Checks if the Vertex feature is enabled (based on database configuration only).\n * @returns {boolean} True if Vertex AI is enabled, false otherwise.\n */\nfunction isVertexEnabled() {\n    // Check if we have service account JSON or Express API Key from database\n    // This is a synchronous check based on initialization state\n    return !!VERTEX_JSON_STRING || isUsingExpressMode;\n}\n\n/**\n * Reinitializes Vertex credentials with database configuration.\n * This function is called when the configuration is updated via the admin panel.\n */\nasync function reinitializeWithDatabaseConfig() {\n    console.log(\"Reinitializing Vertex AI with database configuration...\");\n\n    // Reset initialization state\n    isVertexInitialized = false;\n    isUsingExpressMode = false;\n    VERTEX_JSON_STRING = null;\n\n    // Clean up existing credentials file if it exists\n    if (tempCredentialsPath) {\n        try {\n            const fs = require('fs').promises;\n            await fs.unlink(tempCredentialsPath);\n            console.log(\"Cleaned up previous credentials file\");\n        } catch (error) {\n            console.warn(\"Failed to clean up previous credentials file:\", error);\n        }\n        tempCredentialsPath = null;\n    }\n\n    // Clear environment variable\n    delete process.env.GOOGLE_APPLICATION_CREDENTIALS;\n\n    // Reinitialize with new configuration\n    await initializeVertexCredentials();\n\n    console.log(\"Vertex AI reinitialization completed\");\n}\n\nmodule.exports = {\n    proxyVertexChatCompletions,\n    getVertexSupportedModels,\n    isVertexEnabled, // Export check function\n    reinitializeWithDatabaseConfig, // Export reinitialization function\n    initializeVertexCredentials // Export initialization function for delayed init\n};\n"
  },
  {
    "path": "src/utils/githubSync.js",
    "content": "const { Octokit } = require('@octokit/rest');\nconst fs = require('fs').promises;\nconst path = require('path');\nconst crypto = require('crypto');\n\nclass GitHubSync {\n  constructor(repoName, token, dbPath, encryptKey) {\n    this.repoName = repoName;\n    this.token = token;\n    this.dbPath = dbPath;\n    this.encryptKey = encryptKey;\n    \n    // Parse GitHub repo owner and name\n    const repoNameParts = this.repoName.split('/');\n    if (repoNameParts.length !== 2 || !repoNameParts[0] || !repoNameParts[1]) {\n      console.error(`Invalid GitHub repository format: \"${repoName}\", should be \"username/repo-name\" format`);\n      this.isValid = false;\n    } else {\n      this.owner = repoNameParts[0];\n      this.repo = repoNameParts[1];\n      this.isValid = true;\n      \n      // Initialize Octokit with the token\n      this.octokit = new Octokit({\n        auth: this.token\n      });\n    }\n\n    // Log if encryption is enabled with a valid key\n    if (this.isConfigured() && this.isEncryptionEnabled()) {\n      console.log(`Using encrypt key: ${this.encryptKey}`);\n    }\n\n    this.initialSyncCompleted = false;\n    \n    // Sync scheduling variables\n    this.pendingSync = false;\n    this.syncTimer = null;\n    this.syncDelay = 300000; // 5 minute delay\n  }\n\n  // Check if GitHub sync is configured and enabled\n  isConfigured() {\n    return this.isValid && this.repoName && this.token && this.owner && this.repo;\n  }\n\n  // Check if encryption is configured\n  isEncryptionEnabled() {\n    return !!this.encryptKey && this.encryptKey.length >= 32;\n  }\n\n  // Validate if a buffer has a valid SQLite header\n  validateSQLiteHeader(data) {\n    if (!data || data.length < 16) {\n      return false;\n    }\n\n    const sqliteHeader = Buffer.from(\"SQLite format 3\\0\");\n    const fileHeader = data.subarray(0, 16);\n\n    return Buffer.compare(fileHeader, sqliteHeader) === 0;\n  }\n\n  // Check if a buffer appears to be encrypted with our format\n  isEncryptedData(data) {\n    // A simple check to determine if data is likely encrypted:\n    // Our encrypted format has a 16-byte IV at the beginning,\n    // and encrypted SQLite databases won't start with the standard SQLite header\n    if (!data || data.length < 20) return false;\n\n    // If encryption is enabled and the data doesn't match SQLite format\n    // it's likely encrypted\n    if (this.isEncryptionEnabled()) {\n      // If the data doesn't have a valid SQLite header, it might be encrypted\n      if (!this.validateSQLiteHeader(data)) {\n        return true;\n      }\n    }\n\n    return false;\n  }\n\n  // Encrypt the database file\n  async encryptData(data) {\n    if (!this.isEncryptionEnabled()) {\n      console.log('Encryption key not provided or too short. Skipping encryption.');\n      return data;\n    }\n\n    // If data is already encrypted, don't re-encrypt it\n    if (this.isEncryptedData(data)) {\n      console.log('Data appears to be already encrypted. Skipping encryption.');\n      return data;\n    }\n\n    try {\n      // Generate a random initialization vector\n      const iv = crypto.randomBytes(16);\n      \n      // Create cipher with AES-256-CBC using the key and iv\n      const key = crypto.createHash('sha256').update(this.encryptKey).digest();\n      const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);\n      \n      // Encrypt the data\n      const encrypted = Buffer.concat([\n        cipher.update(data),\n        cipher.final()\n      ]);\n      \n      // Prepend the IV to the encrypted data\n      const result = Buffer.concat([iv, encrypted]);\n      \n      console.log('Data successfully encrypted');\n      return result;\n    } catch (error) {\n      console.error('Error encrypting data:', error.message);\n      return data; // Return original data on error\n    }\n  }\n\n  // Decrypt the database file\n  async decryptData(data) {\n    if (!this.isEncryptionEnabled()) {\n      console.log('Encryption key not provided or too short. Skipping decryption.');\n      return data;\n    }\n\n    // If data doesn't appear to be encrypted, don't try to decrypt it\n    if (!this.isEncryptedData(data)) {\n      console.log('Data appears to be in plain text. Skipping decryption.');\n      return data;\n    }\n\n    try {\n      // Extract the IV from the first 16 bytes\n      const iv = data.slice(0, 16);\n      const encryptedData = data.slice(16);\n      \n      // Create decipher with AES-256-CBC using the key and iv\n      const key = crypto.createHash('sha256').update(this.encryptKey).digest();\n      const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);\n      \n      // Decrypt the data\n      const decrypted = Buffer.concat([\n        decipher.update(encryptedData),\n        decipher.final()\n      ]);\n      \n      console.log('Data successfully decrypted');\n      return decrypted;\n    } catch (error) {\n      console.error('Error decrypting data:', error.message);\n      return data; // Return original data on error\n    }\n  }\n\n  // Download database from GitHub and overwrite local file\n  async downloadDatabase() {\n    if (!this.isConfigured()) {\n      console.log('GitHub sync not configured. Skipping download.');\n      return false;\n    }\n\n    try {\n      console.log(`Attempting to download database from GitHub repository: ${this.repoName}`);\n      \n      // Get the content of the database file from GitHub\n      // First, try to get the file info to check if it exists\n      try {\n        const { data } = await this.octokit.repos.getContent({\n          owner: this.owner,\n          repo: this.repo,\n          path: 'database.db',\n        });\n\n        // If the file exists, download the binary content\n        if (data && data.download_url) {\n          const response = await fetch(data.download_url);\n          \n          if (!response.ok) {\n            throw new Error(`Failed to download file: ${response.statusText}`);\n          }\n          \n          // Get the file as ArrayBuffer\n          const arrayBuffer = await response.arrayBuffer();\n          let buffer = Buffer.from(arrayBuffer);\n          \n          // Check if the data appears to be encrypted\n          const isEncrypted = this.isEncryptedData(buffer);\n          \n          // Decrypt the data if encryption is enabled and data appears encrypted\n          if (this.isEncryptionEnabled() && isEncrypted) {\n            console.log('Downloaded database file is encrypted, decrypting...');\n            try {\n              buffer = await this.decryptData(buffer);\n            } catch (decryptError) {\n              console.error('Failed to decrypt database:', decryptError.message);\n              console.error('Database file may be corrupted or encryption key is incorrect');\n              return false; // Don't save corrupted data\n            }\n          } else if (this.isEncryptionEnabled() && !isEncrypted) {\n            console.log('Downloaded database file is plaintext, skipping decryption (plaintext to encrypted transition phase)');\n          } else {\n            console.log('Downloaded database file is plaintext');\n          }\n\n          // Validate the database file before saving\n          if (this.validateSQLiteHeader(buffer)) {\n            // Write the file to local path\n            await fs.writeFile(this.dbPath, buffer);\n            console.log('Database successfully downloaded and saved locally');\n            this.initialSyncCompleted = true;\n            return true;\n          } else {\n            console.error('Downloaded database file has invalid SQLite header, not saving');\n            return false;\n          }\n        }\n      } catch (error) {\n        // File doesn't exist or other error\n        if (error.status === 404) {\n          console.log('Database file not found on GitHub. This appears to be the first run.');\n          console.log('Marking initial sync as completed to allow future uploads.');\n          this.initialSyncCompleted = true;\n        } else {\n          console.error('Error checking database file on GitHub:', error.message);\n        }\n        return false;\n      }\n    } catch (error) {\n      console.error('Error downloading database from GitHub:', error.message);\n      return false;\n    }\n  }\n\n  // Schedule a GitHub sync\n  scheduleSync() {\n    // If a sync is already scheduled, just mark as pending (to avoid multiple timers)\n    if (this.syncTimer) {\n      console.log('Sync already scheduled. Marking as pending.');\n      this.pendingSync = true;\n      return; // No need to return a promise here, scheduling is synchronous\n    }\n\n    // Otherwise, schedule a new sync\n    console.log(`Scheduling GitHub sync with ${this.syncDelay / 1000} second delay`);\n    this.pendingSync = true;\n\n    this.syncTimer = setTimeout(async () => {\n      // Reset flags before starting the upload\n      this.pendingSync = false;\n      this.syncTimer = null;\n\n      console.log('Starting GitHub sync...');\n      try {\n        await this.uploadDatabase();\n        console.log('GitHub sync completed successfully');\n      } catch (error) {\n        console.error('Error during GitHub sync:', error.message);\n      }\n    }, this.syncDelay);\n\n    // Return immediately after scheduling\n    return Promise.resolve(true);\n  }\n\n  // Upload database to GitHub\n  async uploadDatabase() {\n    if (!this.isConfigured()) {\n      console.log('GitHub sync not configured. Skipping upload.');\n      return false;\n    }\n\n    if (!this.initialSyncCompleted) {\n      console.log('Initial sync not completed. Skipping upload to prevent overwriting remote data.');\n      return false;\n    }\n\n    try {\n      console.log(`Uploading database to GitHub repository: ${this.repoName}`);\n      \n      // Read the local database file\n      let content = await fs.readFile(this.dbPath);\n      \n      // Encrypt the data if encryption is enabled\n      if (this.isEncryptionEnabled()) {\n        // Only encrypt if not already encrypted\n        if (!this.isEncryptedData(content)) {\n          console.log('Encrypting database before upload...');\n          try {\n            content = await this.encryptData(content);\n          } catch (encryptError) {\n            console.error('Failed to encrypt database:', encryptError.message);\n            console.log('Using the unencrypted version as fallback');\n          }\n        } else {\n          console.log('Data is already encrypted, skipping re-encryption');\n        }\n      }\n      \n      // Convert to base64\n      const contentEncoded = content.toString('base64');\n      \n      // Try to get the file SHA if it exists (needed for update)\n      let fileSha;\n      try {\n        const { data } = await this.octokit.repos.getContent({\n          owner: this.owner,\n          repo: this.repo,\n          path: 'database.db',\n        });\n        fileSha = data.sha;\n      } catch (error) {\n        // File doesn't exist yet, which is fine\n      }\n      \n      // Create or update the file on GitHub\n      await this.octokit.repos.createOrUpdateFileContents({\n        owner: this.owner,\n        repo: this.repo,\n        path: 'database.db',\n        message: 'Update database',\n        content: contentEncoded,\n        sha: fileSha, // If undefined, GitHub will create a new file\n      });\n      \n      console.log('Database successfully uploaded to GitHub');\n      return true;\n    } catch (error) {\n      console.error('Error uploading database to GitHub:', error.message);\n      return false;\n    }\n  }\n}\n\nmodule.exports = GitHubSync;\n"
  },
  {
    "path": "src/utils/helpers.js",
    "content": "/**\n * Helper function to get today's date in Los Angeles timezone (YYYY-MM-DD format)\n * Uses a more reliable method for timezone conversion\n */\nfunction getTodayInLA() {\n\t// Get current date in Los Angeles timezone\n\tconst date = new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles' });\n\t// Parse the date string into a Date object\n\tconst laDate = new Date(date);\n\t// Format as YYYY-MM-DD\n\treturn laDate.getFullYear() + '-' +\n\t\tString(laDate.getMonth() + 1).padStart(2, '0') + '-' +\n\t\tString(laDate.getDate()).padStart(2, '0');\n}\n\n/**\n * Helper to parse JSON body safely from Express request.\n * Note: Express middleware (express.json()) usually handles this,\n * but this can be a fallback or used if middleware isn't applied globally.\n * @param {Request} req - Express request object\n * @returns {Promise<object|null>} - Parsed body or null on error\n */\nasync function readRequestBody(req) {\n    // Express's body-parser middleware (express.json()) already parses the body\n    // and attaches it to req.body. We can directly return it.\n    // Add a check in case the middleware wasn't used or failed.\n    if (req.body) {\n        return req.body;\n    }\n    // Fallback if req.body is not populated (e.g., middleware issue or raw request)\n    // This part is less likely needed with standard Express setup but kept for robustness.\n    try {\n        // Manually read and parse if needed (requires different setup, typically not necessary)\n        // For standard Express, this block might not execute if express.json() is used correctly.\n        console.warn(\"req.body not populated, attempting manual parse (may indicate middleware issue)\");\n        // Example of manual parsing (would need raw body stream):\n        // const buffer = await req.read(); // Hypothetical method\n        // return JSON.parse(buffer.toString());\n        return null; // Return null if req.body is missing\n    } catch (e) {\n        console.error(\"Error reading request body:\", e);\n        return null;\n    }\n}\n\n\n// --- CORS Helper (for reference, but handled by 'cors' middleware in server.js) ---\n// function corsHeaders() {\n// \treturn {\n// \t\t'Access-Control-Allow-Origin': '*',\n// \t\t'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',\n// \t\t'Access-Control-Allow-Headers': 'Authorization, Content-Type, x-requested-with',\n// \t\t'Access-Control-Max-Age': '86400',\n// \t};\n// }\n\nmodule.exports = {\n    getTodayInLA,\n    readRequestBody,\n    // corsHeaders // Not exporting as it's handled by middleware\n};\n"
  },
  {
    "path": "src/utils/proxyPool.js",
    "content": "let SocksProxyAgent; // Declare variable\ntry {\n    SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent; // Try importing\n} catch (e) {\n    console.warn(\"Optional dependency 'socks-proxy-agent' not found. SOCKS5 proxy functionality will be unavailable unless this dependency is installed.\");\n    SocksProxyAgent = null; // Set to null if import fails\n}\n\nlet proxies = [];\nlet currentProxyIndex = 0;\n\nfunction initializeProxyPool() {\n    proxies = []; // Reset proxies on re-initialization\n    currentProxyIndex = 0;\n    const proxyEnv = process.env.PROXY;\n    if (proxyEnv) {\n        proxies = proxyEnv.split(',')\n            .map(proxyStr => proxyStr.trim())\n            .filter(proxyStr => {\n                if (proxyStr.startsWith('socks5://')) {\n                    return true;\n                }\n                if (proxyStr) { // Log invalid format only if non-empty string\n                    console.warn(`Invalid proxy format skipped: \"${proxyStr}\". Only socks5:// is supported.`);\n                }\n                return false;\n            });\n\n        if (proxies.length > 0) {\n             // This log will now be printed by index.js using getProxyPoolStatus\n            // console.log(`Initialized proxy pool with ${proxies.length} SOCKS5 proxies.`);\n        } else {\n             // This log will now be printed by index.js using getProxyPoolStatus\n            // console.log('PROXY environment variable found but contains no valid SOCKS5 proxies.');\n        }\n    } else {\n         // This log will now be printed by index.js using getProxyPoolStatus\n        // console.log('PROXY environment variable not set. No proxy will be used.');\n    }\n}\n\nfunction getNextProxyAgent() {\n    if (proxies.length === 0 || !SocksProxyAgent) {\n        return undefined; // No proxies configured or agent not available\n    }\n    const proxyUrl = proxies[currentProxyIndex];\n    currentProxyIndex = (currentProxyIndex + 1) % proxies.length; // Rotate index\n    try {\n        // Log proxy usage within the service where it's called for better context\n        // console.log(`Using proxy: ${proxyUrl}`); \n        return new SocksProxyAgent(proxyUrl);\n    } catch (e) {\n        console.error(`Error creating proxy agent for ${proxyUrl}:`, e);\n        return undefined; // Return undefined if agent creation fails\n    }\n}\n\n// Function to get the status of the proxy pool\nfunction getProxyPoolStatus() {\n    const enabled = proxies.length > 0 && !!SocksProxyAgent; // Enabled if proxies exist AND agent is loaded\n    return {\n        enabled: enabled,\n        count: proxies.length,\n        agentLoaded: !!SocksProxyAgent // Explicitly indicate if the agent dependency loaded\n    };\n}\n\n// Initialize the proxy pool when the module loads\ninitializeProxyPool();\n\nmodule.exports = {\n    initializeProxyPool, // Export for potential re-initialization if needed\n    getNextProxyAgent,\n    getProxyPoolStatus,\n};\n"
  },
  {
    "path": "src/utils/session.js",
    "content": "const crypto = require('crypto');\n\nconst SESSION_COOKIE_NAME = '__session';\nconst SESSION_DURATION_SECONDS = 1 * 60 * 60; // 1 hour\n\n// Auto-generate session secret key on startup for better security\nconst SESSION_SECRET_KEY = crypto.randomBytes(32).toString('hex');\nconsole.log(\"Auto-generated session secret key for this session.\");\n\n/**\n * Converts Buffer to Base64 URL safe string.\n * @param {Buffer} buffer\n * @returns {string}\n */\nfunction bufferToBase64Url(buffer) {\n    return buffer.toString('base64')\n        .replace(/\\+/g, '-')\n        .replace(/\\//g, '_')\n        .replace(/=+$/, '');\n}\n\n/**\n * Converts Base64 URL safe string to Buffer.\n * @param {string} base64url\n * @returns {Buffer}\n */\nfunction base64UrlToBuffer(base64url) {\n    base64url = base64url.replace(/-/g, '+').replace(/_/g, '/');\n    // Padding is handled automatically by Buffer.from in newer Node versions\n    return Buffer.from(base64url, 'base64');\n}\n\n/**\n * Generates a signed session token.\n * Payload: { exp: number }\n * @returns {Promise<string|null>} Session token or null on error.\n */\nasync function generateSessionToken() {\n    try {\n        const expiration = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;\n        const payload = JSON.stringify({ exp: expiration });\n        const encodedPayload = bufferToBase64Url(Buffer.from(payload));\n\n        // Use Node.js crypto for HMAC\n        const hmac = crypto.createHmac('sha256', SESSION_SECRET_KEY);\n        hmac.update(encodedPayload);\n        const signature = hmac.digest(); // Returns a Buffer\n        const encodedSignature = bufferToBase64Url(signature);\n\n        return `${encodedPayload}.${encodedSignature}`;\n    } catch (e) {\n        console.error(\"Error generating session token:\", e);\n        return null;\n    }\n}\n\n/**\n * Verifies the signature and expiration of a session token.\n * @param {string} token - The session token string.\n * @returns {Promise<boolean>} True if valid and not expired, false otherwise.\n */\nasync function verifySessionToken(token) {\n    if (!token) {\n        return false;\n    }\n    try {\n        const parts = token.split('.');\n        if (parts.length !== 2) return false;\n\n        const [encodedPayload, encodedSignature] = parts;\n        const signatureBuffer = base64UrlToBuffer(encodedSignature);\n\n        // Recalculate HMAC signature for comparison\n        const hmac = crypto.createHmac('sha256', SESSION_SECRET_KEY);\n        hmac.update(encodedPayload);\n        const expectedSignatureBuffer = hmac.digest();\n\n        // Compare signatures using timing-safe comparison\n        if (!crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer)) {\n            console.warn(\"Session token signature mismatch.\");\n            return false;\n        }\n\n        // Decode payload and check expiration\n        const payloadJson = base64UrlToBuffer(encodedPayload).toString();\n        const payload = JSON.parse(payloadJson);\n\n        const now = Math.floor(Date.now() / 1000);\n        if (payload.exp <= now) {\n            console.log(\"Session token expired.\");\n            return false;\n        }\n\n        return true; // Token is valid and not expired\n\n    } catch (e) {\n        console.error(\"Error verifying session token:\", e);\n        return false;\n    }\n}\n\n/**\n * Extracts the session token from the request's cookies.\n * Uses cookie-parser middleware result.\n * @param {import('express').Request} req - Express request object.\n * @returns {string | null} The session token or null.\n */\nfunction getSessionTokenFromCookie(req) {\n    // cookie-parser middleware populates req.cookies\n    return req.cookies?.[SESSION_COOKIE_NAME] || null;\n}\n\n/**\n * Sets the session cookie on the response.\n * @param {import('express').Response} res - Express response object.\n * @param {string} token - The session token.\n */\nfunction setSessionCookie(res, token) {\n    const expires = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);\n    res.cookie(SESSION_COOKIE_NAME, token, {\n        path: '/',\n        expires: expires,\n        httpOnly: true,\n        secure: process.env.NODE_ENV === 'production', // Use secure cookies in production\n        sameSite: 'Lax' // Protects against CSRF to some extent\n    });\n}\n\n/**\n * Clears the session cookie on the response.\n * @param {import('express').Response} res - Express response object.\n */\nfunction clearSessionCookie(res) {\n    res.cookie(SESSION_COOKIE_NAME, '', {\n        path: '/',\n        expires: new Date(0), // Set expiry date to the past\n        httpOnly: true,\n        secure: process.env.NODE_ENV === 'production',\n        sameSite: 'Lax'\n    });\n}\n\n/**\n * Verifies the session cookie from the request.\n * @param {import('express').Request} req - Express request object.\n * @returns {Promise<boolean>} True if the session is valid.\n */\nasync function verifySessionCookie(req) {\n    const token = getSessionTokenFromCookie(req);\n    if (!token) {\n        return false;\n    }\n    return await verifySessionToken(token);\n}\n\nmodule.exports = {\n    generateSessionToken,\n    verifySessionToken,\n    getSessionTokenFromCookie,\n    setSessionCookie,\n    clearSessionCookie,\n    verifySessionCookie,\n    SESSION_COOKIE_NAME,\n};\n"
  },
  {
    "path": "src/utils/transform.js",
    "content": "// --- Transformation logic migrated from Cloudflare Worker ---\n\n/**\n * Parses a data URI string.\n * @param {string} dataUri - The data URI (e.g., \"data:image/jpeg;base64,...\").\n * @returns {{ mimeType: string; data: string } | null} Parsed data or null if invalid.\n */\nfunction parseDataUri(dataUri) {\n    if (!dataUri) return null;\n\tconst match = dataUri.match(/^data:(.+?);base64,(.+)$/);\n\tif (!match) return null;\n\treturn { mimeType: match[1], data: match[2] };\n}\n\n/**\n * Transforms an OpenAI-compatible request body to the Gemini API format.\n * @param {object} requestBody - The OpenAI request body.\n * @param {string} [requestedModelId] - The specific model ID requested.\n * @param {boolean} [isSafetyEnabled=true] - Whether safety filtering is enabled for this request.\n * @returns {{ contents: any[]; systemInstruction?: any; tools?: any[]; toolConfig?: any }} Gemini formatted request parts.\n */\nfunction transformOpenAiToGemini(requestBody, requestedModelId, isSafetyEnabled = true) {\n\tconst messages = requestBody.messages || [];\n\tconst openAiTools = requestBody.tools;\n\tconst openAiToolChoice = requestBody.tool_choice;\n\n\t// 1. Transform Messages\n\tconst contents = [];\n\tlet systemInstruction = undefined;\n\tlet systemMessageLogPrinted = false; // Add flag to track if log has been printed\n\n\tmessages.forEach((msg) => {\n\t\tlet role = undefined;\n\t\tlet parts = [];\n\n\t\t// 1. Map Role\n\t\tswitch (msg.role) {\n\t\t\tcase 'user':\n\t\t\t\trole = 'user';\n\t\t\t\tbreak;\n\t\t\tcase 'assistant':\n\t\t\t\trole = 'model';\n\t\t\t\tbreak;\n\t\t\tcase 'system':\n                // If safety is disabled OR it's a gemma model, treat system as user\n                if (isSafetyEnabled === false || (requestedModelId && requestedModelId.startsWith('gemma'))) {\n                    // Only print the log message for the first system message encountered\n                    if (!systemMessageLogPrinted) {\n                        console.log(`Safety disabled (${isSafetyEnabled}) or Gemma model detected (${requestedModelId}). Treating system message as user message.`);\n                        systemMessageLogPrinted = true;\n                    }\n                    role = 'user';\n                    // Content processing for 'user' role will happen below\n                }\n                // Otherwise (safety enabled and not gemma), create systemInstruction\n                else {\n                    if (typeof msg.content === 'string') {\n                        systemInstruction = { role: \"system\", parts: [{ text: msg.content }] };\n                    } else if (Array.isArray(msg.content)) { // Handle complex system prompts if needed\n                        const textContent = msg.content.find((p) => p.type === 'text')?.text;\n                        if (textContent) {\n                            systemInstruction = { role: \"system\", parts: [{ text: textContent }] };\n                        }\n                    }\n                    return; // Skip adding this message to 'contents' when creating systemInstruction\n                }\n                break; // Break for 'system' role (safety disabled/gemma case falls through to content processing)\n\t\t\tdefault:\n\t\t\t\tconsole.warn(`Unknown role encountered: ${msg.role}. Skipping message.`);\n\t\t\t\treturn; // Skip unknown roles\n\t\t}\n\n\t\t// 2. Map Content to Parts\n\t\tif (typeof msg.content === 'string') {\n\t\t\tparts.push({ text: msg.content });\n\t\t} else if (Array.isArray(msg.content)) {\n\t\t\t// Handle multi-part messages (text and images)\n\t\t\tmsg.content.forEach((part) => {\n\t\t\t\tif (part.type === 'text') {\n\t\t\t\t\tparts.push({ text: part.text });\n\t\t\t\t} else if (part.type === 'image_url') {\n                    // In Node.js, image_url might just contain the URL, or a data URI\n                    // Assuming it follows the OpenAI spec and provides a URL field within image_url\n                    const imageUrl = part.image_url?.url;\n                    if (!imageUrl) {\n                        console.warn(`Missing url in image_url part. Skipping image part.`);\n                        return;\n                    }\n\t\t\t\t\tconst imageData = parseDataUri(imageUrl); // Attempt to parse as data URI\n\t\t\t\t\tif (imageData) {\n\t\t\t\t\t\tparts.push({ inlineData: { mimeType: imageData.mimeType, data: imageData.data } }); // Structure expected by Gemini\n\t\t\t\t\t} else {\n                        // If it's not a data URI, we can't directly include it as inlineData.\n                        // Gemini API (currently) doesn't support fetching from URLs directly in the standard API.\n                        // Consider alternatives:\n                        // 1. Pre-fetch the image data server-side (adds complexity, requires fetch).\n                        // 2. Reject requests with image URLs (simpler for now).\n                        console.warn(`Image URL is not a data URI: ${imageUrl}. Gemini API requires inlineData (base64). Skipping image part.`);\n                        // Decide how to handle this. For now, we skip.\n                        // parts.push({ text: `[Unsupported Image URL: ${imageUrl}]` }); // Optional: replace with text placeholder\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconsole.warn(`Unknown content part type: ${part.type}. Skipping part.`);\n\t\t\t\t}\n\t\t\t});\n\t\t} else {\n\t\t\tconsole.warn(`Unsupported content type for role ${msg.role}: ${typeof msg.content}. Skipping message.`);\n\t\t\treturn;\n\t\t}\n\n\t\t// Add the transformed message to contents if it has a role and parts\n\t\tif (role && parts.length > 0) {\n\t\t\tcontents.push({ role, parts });\n\t\t}\n\t});\n\n\t// 2. Transform Tools\n\tlet geminiTools = undefined;\n\tif (openAiTools && Array.isArray(openAiTools) && openAiTools.length > 0) {\n\t\tconst functionDeclarations = openAiTools\n\t\t\t.filter(tool => tool.type === 'function' && tool.function)\n\t\t\t.map(tool => {\n                // Deep clone parameters to avoid modifying the original request object\n                const parameters = tool.function.parameters ? JSON.parse(JSON.stringify(tool.function.parameters)) : undefined;\n                // Remove the $schema field if it exists in the clone\n                if (parameters && parameters.$schema !== undefined) {\n                    delete parameters.$schema;\n                    console.log(`Removed '$schema' from parameters for tool: ${tool.function.name}`);\n                }\n\t\t\t\treturn {\n\t\t\t\t\tname: tool.function.name,\n\t\t\t\t\tdescription: tool.function.description,\n\t\t\t\t\tparameters: parameters\n\t\t\t\t};\n\t\t\t});\n\n\t\tif (functionDeclarations.length > 0) {\n\t\t\tgeminiTools = [{ functionDeclarations }];\n\t\t}\n\t}\n\n\t// 3. Transform Tool Choice to Tool Config\n\tlet toolConfig = undefined;\n\tif (openAiToolChoice && geminiTools && geminiTools.length > 0) {\n\t\tconst functionCallingConfig = {};\n\n\t\tif (typeof openAiToolChoice === 'string') {\n\t\t\tswitch (openAiToolChoice) {\n\t\t\t\tcase 'auto':\n\t\t\t\t\tfunctionCallingConfig.mode = 'AUTO';\n\t\t\t\t\tbreak;\n\t\t\t\tcase 'none':\n\t\t\t\t\tfunctionCallingConfig.mode = 'NONE';\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\t// If it's a string but not 'auto' or 'none', treat it as a specific function name\n\t\t\t\t\tfunctionCallingConfig.mode = 'ANY';\n\t\t\t\t\tfunctionCallingConfig.allowedFunctionNames = [openAiToolChoice];\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t} else if (typeof openAiToolChoice === 'object' && openAiToolChoice.type === 'function') {\n\t\t\t// Handle {\"type\": \"function\", \"function\": {\"name\": \"function_name\"}}\n\t\t\tconst functionName = openAiToolChoice.function?.name;\n\t\t\tif (functionName) {\n\t\t\t\tfunctionCallingConfig.mode = 'ANY';\n\t\t\t\tfunctionCallingConfig.allowedFunctionNames = [functionName];\n\t\t\t} else {\n\t\t\t\t// Fallback to AUTO if function name is missing\n\t\t\t\tfunctionCallingConfig.mode = 'AUTO';\n\t\t\t}\n\t\t} else {\n\t\t\t// Default to AUTO for any other cases\n\t\t\tfunctionCallingConfig.mode = 'AUTO';\n\t\t}\n\n\t\ttoolConfig = { functionCallingConfig };\n\t\tconsole.log(`Tool choice transformed: ${JSON.stringify(openAiToolChoice)} -> ${JSON.stringify(toolConfig)}`);\n\t}\n\n\treturn { contents, systemInstruction, tools: geminiTools, toolConfig };\n}\n\n\n/**\n * Transforms a single Gemini API stream chunk into an OpenAI-compatible SSE chunk.\n * @param {object} geminiChunk - The parsed JSON object from a Gemini stream line.\n * @param {string} modelId - The model ID used for the request.\n * @returns {string | null} An OpenAI SSE data line string (\"data: {...}\\n\\n\") or null if chunk is empty/invalid.\n */\nfunction transformGeminiStreamChunk(geminiChunk, modelId) {\n\ttry {\n\t\tif (!geminiChunk || !geminiChunk.candidates || !geminiChunk.candidates.length) {\n            // Ignore chunks that only contain usageMetadata (often appear at the end)\n            if (geminiChunk?.usageMetadata) {\n                return null;\n            }\n\t\t\tconsole.warn(\"Received empty or invalid Gemini stream chunk:\", JSON.stringify(geminiChunk));\n\t\t\treturn null; // Skip empty/invalid chunks\n\t\t}\n\n\t\tconst candidate = geminiChunk.candidates[0];\n\t\tlet contentText = null;\n\t\tlet toolCalls = undefined;\n\n\t\t// Extract text content and function calls\n        if (candidate.content?.parts?.length > 0) {\n            const textParts = candidate.content.parts.filter((part) => part.text !== undefined);\n            const functionCallParts = candidate.content.parts.filter((part) => part.functionCall !== undefined);\n\n            if (textParts.length > 0) {\n                contentText = textParts.map((part) => part.text).join(\"\");\n            }\n\n            if (functionCallParts.length > 0) {\n                // Generate unique IDs for tool calls within the stream context if needed,\n                // or use a simpler identifier if absolute uniqueness isn't critical across chunks.\n                toolCalls = functionCallParts.map((part, index) => ({\n                    index: index, // Gemini doesn't provide a stable index in stream AFAIK, use loop index\n                    id: `call_${part.functionCall.name}_${Date.now()}_${index}`, // Example ID generation\n                    type: \"function\",\n                    function: {\n                        name: part.functionCall.name,\n                        // Arguments in Gemini stream might be partial JSON, attempt to stringify\n                        arguments: JSON.stringify(part.functionCall.args || {}),\n                    },\n                }));\n            }\n        }\n\n\t\t// Determine finish reason mapping\n\t\tlet finishReason = candidate.finishReason;\n        if (finishReason === \"STOP\") finishReason = \"stop\";\n        else if (finishReason === \"MAX_TOKENS\") finishReason = \"length\";\n        else if (finishReason === \"SAFETY\" || finishReason === \"RECITATION\") finishReason = \"content_filter\";\n        else if (finishReason === \"TOOL_CALLS\" || (toolCalls && toolCalls.length > 0 && finishReason !== 'stop' && finishReason !== 'length')) {\n            // If there are tool calls and the reason isn't stop/length, map it to tool_calls\n            finishReason = \"tool_calls\";\n        } else if (finishReason && finishReason !== \"FINISH_REASON_UNSPECIFIED\" && finishReason !== \"OTHER\") {\n            // Keep known reasons like 'stop', 'length', 'content_filter'\n        } else {\n            finishReason = null; // Map unspecified/other/null to null\n        }\n\n\n\t\t// Construct the delta part for the OpenAI chunk\n\t\tconst delta = {};\n        // Include role only if there's actual content or tool calls in this chunk\n        if (candidate.content?.role && (contentText !== null || (toolCalls && toolCalls.length > 0))) {\n            delta.role = candidate.content.role === 'model' ? 'assistant' : candidate.content.role;\n        }\n\n        if (toolCalls && toolCalls.length > 0) {\n            delta.tool_calls = toolCalls;\n             // IMPORTANT: Explicitly set content to null if there are tool_calls but no text content in THIS chunk\n             // This aligns with OpenAI's behavior where a chunk might contain only tool_calls.\n            if (contentText === null) {\n                delta.content = null;\n            } else {\n                 delta.content = contentText; // Include text if it also exists\n            }\n        } else if (contentText !== null) {\n             // Only include content if there's text and no tool calls in this chunk\n            delta.content = contentText;\n        }\n\n\n\t\t// Only create a chunk if there's something meaningful to send\n\t\tif (Object.keys(delta).length === 0 && !finishReason) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst openaiChunk = {\n\t\t\tid: `chatcmpl-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, // More unique ID\n\t\t\tobject: \"chat.completion.chunk\",\n\t\t\tcreated: Math.floor(Date.now() / 1000),\n\t\t\tmodel: modelId,\n\t\t\tchoices: [\n\t\t\t\t{\n\t\t\t\t\tindex: candidate.index || 0,\n\t\t\t\t\tdelta: delta,\n\t\t\t\t\tfinish_reason: finishReason, // Use the mapped finishReason\n                    logprobs: null, // Not provided by Gemini\n\t\t\t\t},\n\t\t\t],\n            // Usage is typically not included in stream chunks, only at the end if at all\n\t\t};\n\n\t\treturn `data: ${JSON.stringify(openaiChunk)}\\n\\n`;\n\n\t} catch (e) {\n\t\tconsole.error(\"Error transforming Gemini stream chunk:\", e, \"Chunk:\", JSON.stringify(geminiChunk));\n        // Optionally return an error chunk\n        const errorChunk = {\n            id: `chatcmpl-error-${Date.now()}`,\n            object: \"chat.completion.chunk\",\n            created: Math.floor(Date.now() / 1000),\n            model: modelId,\n            choices: [{ index: 0, delta: { content: `[Error transforming chunk: ${e.message}]` }, finish_reason: 'error' }]\n        };\n        return `data: ${JSON.stringify(errorChunk)}\\n\\n`;\n\t}\n}\n\n\n/**\n * Transforms a complete (non-streaming) Gemini API response into an OpenAI-compatible format.\n * @param {object} geminiResponse - The parsed JSON object from the Gemini API response.\n * @param {string} modelId - The model ID used for the request.\n * @returns {string} A JSON string representing the OpenAI-compatible response.\n */\nfunction transformGeminiResponseToOpenAI(geminiResponse, modelId) {\n\ttry {\n        // Handle cases where the response indicates an error (e.g., blocked prompt)\n        if (!geminiResponse.candidates || geminiResponse.candidates.length === 0) {\n            let errorMessage = \"Gemini response missing candidates.\";\n            let finishReason = \"error\"; // Default error finish reason\n\n            // Check for prompt feedback indicating blocking\n            if (geminiResponse.promptFeedback?.blockReason) {\n                errorMessage = `Request blocked by Gemini: ${geminiResponse.promptFeedback.blockReason}.`;\n                finishReason = \"content_filter\"; // More specific finish reason\n                 console.warn(`Gemini request blocked: ${geminiResponse.promptFeedback.blockReason}`, JSON.stringify(geminiResponse.promptFeedback));\n            } else {\n                console.error(\"Invalid Gemini response structure:\", JSON.stringify(geminiResponse));\n            }\n\n            // Construct an error response in OpenAI format\n            const errorResponse = {\n                id: `chatcmpl-error-${Date.now()}`,\n                object: \"chat.completion\",\n                created: Math.floor(Date.now() / 1000),\n                model: modelId,\n                choices: [{\n                    index: 0,\n                    message: { role: \"assistant\", content: errorMessage },\n                    finish_reason: finishReason,\n                    logprobs: null,\n                }],\n                usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n            };\n            return JSON.stringify(errorResponse);\n        }\n\n\n\t\tconst candidate = geminiResponse.candidates[0];\n\t\tlet contentText = null;\n\t\tlet toolCalls = undefined;\n\n\t\t// Extract content and tool calls\n\t\tif (candidate.content?.parts?.length > 0) {\n            const textParts = candidate.content.parts.filter((part) => part.text !== undefined);\n            const functionCallParts = candidate.content.parts.filter((part) => part.functionCall !== undefined);\n\n            if (textParts.length > 0) {\n                contentText = textParts.map((part) => part.text).join(\"\");\n            }\n\n            if (functionCallParts.length > 0) {\n                toolCalls = functionCallParts.map((part, index) => ({\n                    id: `call_${part.functionCall.name}_${Date.now()}_${index}`, // Example ID\n                    type: \"function\",\n                    function: {\n                        name: part.functionCall.name,\n                        // Arguments should be a stringified JSON in OpenAI format\n                        arguments: JSON.stringify(part.functionCall.args || {}),\n                    },\n                }));\n            }\n        }\n\n\t\t// Map finish reason\n\t\tlet finishReason = candidate.finishReason;\n        if (finishReason === \"STOP\") finishReason = \"stop\";\n        else if (finishReason === \"MAX_TOKENS\") finishReason = \"length\";\n        else if (finishReason === \"SAFETY\" || finishReason === \"RECITATION\") finishReason = \"content_filter\";\n        else if (finishReason === \"TOOL_CALLS\") finishReason = \"tool_calls\"; // Explicitly check for TOOL_CALLS\n        else if (toolCalls && toolCalls.length > 0) {\n             // If tools were called but reason is not TOOL_CALLS (e.g., STOP), still map to tool_calls\n            finishReason = \"tool_calls\";\n        } else if (finishReason && finishReason !== \"FINISH_REASON_UNSPECIFIED\" && finishReason !== \"OTHER\") {\n            // Keep known reasons\n        } else {\n             finishReason = null; // Map unspecified/other to null\n        }\n\n        // Handle cases where content might be missing due to safety ratings, even if finishReason isn't SAFETY\n        if (contentText === null && !toolCalls && candidate.finishReason === \"SAFETY\") {\n             console.warn(\"Gemini response finished due to SAFETY, content might be missing.\");\n             contentText = \"[Content blocked due to safety settings]\";\n             finishReason = \"content_filter\";\n        } else if (candidate.finishReason === \"RECITATION\") {\n             console.warn(\"Gemini response finished due to RECITATION.\");\n             // contentText might exist but could be partial/problematic\n             finishReason = \"content_filter\"; // Map recitation to content_filter\n        }\n\n\n\t\t// Construct the OpenAI message object\n\t\tconst message = { role: \"assistant\" };\n        if (toolCalls && toolCalls.length > 0) {\n             message.tool_calls = toolCalls;\n             // IMPORTANT: Set content to null if only tool calls exist, otherwise include text\n             message.content = contentText !== null ? contentText : null;\n        } else {\n             message.content = contentText; // Assign text content if no tool calls\n        }\n         // Ensure content is at least null if nothing else was generated\n         if (message.content === undefined && !message.tool_calls) {\n            message.content = null;\n         }\n\n\n\t\t// Map usage metadata\n\t\tconst usage = {\n\t\t\tprompt_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,\n\t\t\tcompletion_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0, // Sum across candidates if multiple\n\t\t\ttotal_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0,\n\t\t};\n\n\t\t// Construct the final OpenAI response object\n\t\tconst openaiResponse = {\n\t\t\tid: `chatcmpl-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,\n\t\t\tobject: \"chat.completion\",\n\t\t\tcreated: Math.floor(Date.now() / 1000),\n\t\t\tmodel: modelId,\n\t\t\tchoices: [\n\t\t\t\t{\n\t\t\t\t\tindex: candidate.index || 0,\n\t\t\t\t\tmessage: message,\n\t\t\t\t\tfinish_reason: finishReason,\n                    logprobs: null, // Not provided by Gemini\n\t\t\t\t},\n\t\t\t],\n\t\t\tusage: usage,\n            // Include system fingerprint if available (though Gemini doesn't provide one)\n            system_fingerprint: null\n\t\t};\n\n\t\treturn JSON.stringify(openaiResponse);\n\n\t} catch (e) {\n\t\tconsole.error(\"Error transforming Gemini non-stream response:\", e, \"Response:\", JSON.stringify(geminiResponse));\n\t\t// Return an error structure in OpenAI format\n\t\tconst errorResponse = {\n\t\t\tid: `chatcmpl-error-${Date.now()}`,\n\t\t\tobject: \"chat.completion\",\n\t\t\tcreated: Math.floor(Date.now() / 1000),\n\t\t\tmodel: modelId,\n\t\t\tchoices: [{\n\t\t\t\tindex: 0,\n\t\t\t\tmessage: { role: \"assistant\", content: `Error processing Gemini response: ${e.message}` },\n\t\t\t\tfinish_reason: \"error\",\n                logprobs: null,\n\t\t\t}],\n\t\t\tusage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },\n\t\t};\n\t\treturn JSON.stringify(errorResponse);\n\t}\n}\n\n\nmodule.exports = {\n    parseDataUri,\n    transformOpenAiToGemini,\n    transformGeminiStreamChunk,\n    transformGeminiResponseToOpenAI,\n};\n"
  }
]