Compare commits

...
13 Commits
Author SHA1 Message Date
Draculabo 166a92ab94 chore: release 0.1.0
Publish Release / publish (ubuntu-latest) (push) Failing after 5m52s
Publish Release / publish (macos-latest) (push) Canceled after 0s
Publish Release / publish (windows-latest) (push) Canceled after 0s
2026-01-10 02:36:47 +08:00
Draculabo 2a961f8520 fix: nestjs build error 2026-01-10 02:36:23 +08:00
Draculabo db675821b8 chore: add electron images 2026-01-10 02:35:56 +08:00
Draculabo c182a514bf chore: add generate-assets script 2026-01-10 02:35:38 +08:00
Draculabo a079cf5ccc fix: ci build error 2026-01-10 01:17:48 +08:00
Draculabo 2b443251bb chore: add grpc dependencies 2026-01-10 01:07:09 +08:00
Draculabo 7768cf5309 fix: button size 2026-01-10 01:07:09 +08:00
Draculabo 64e27ab821 feat: integrate antigravity adapter 2026-01-10 01:07:09 +08:00
Draculabo f5a85e3436 feat: support LAN network 2026-01-10 01:07:09 +08:00
Draculabo 098cbe3eb9 fix: server di error 2026-01-10 01:07:09 +08:00
Draculabo bc775d4fd6 feat: add boss key 2026-01-10 01:07:09 +08:00
Draculabo e7087bc4b1 feat: migrate gateway to nestjs 2025-12-24 23:26:55 +08:00
Draculabo c4a0fa773f docs: update project document 2025-12-24 23:11:54 +08:00
73 changed files with 8499 additions and 1279 deletions

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
---
name: 🐛 Bug Report
about: Report a bug to help us improve
title: '[Bug] '
labels: bug
assignees: ''
---
## Describe the Bug
A clear and concise description of what the bug is.
## Steps To Reproduce
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Actual Behavior
What actually happened.
## Screenshots
If applicable, add screenshots to help explain your problem.
## Environment
- **OS**: [e.g., Windows 11, macOS Sonoma, Ubuntu 22.04]
- **App Version**: [e.g., 0.0.1]
- **Node.js Version**: [e.g., 18.17.0]
## Additional Context
Add any other context about the problem here.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 📖 Documentation
url: https://github.com/Draculabo/AntigravityManager#readme
about: Check our README for usage information
- name: ❓ Questions
url: https://github.com/Draculabo/AntigravityManager/discussions
about: Ask questions and discuss with the community
+29
View File
@@ -0,0 +1,29 @@
---
name: 💡 Feature Request
about: Suggest an idea for this project
title: '[Feature] '
labels: enhancement
assignees: ''
---
## Is your feature request related to a problem?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the solution you'd like
A clear and concise description of what you want to happen.
## Describe alternatives you've considered
A clear and concise description of any alternative solutions or features you've considered.
## Additional context
Add any other context or screenshots about the feature request here.
## Are you willing to contribute?
- [ ] Yes, I'd like to implement this feature
- [ ] Yes, I can help with testing
- [ ] No, I'm just suggesting
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y rpm
sudo apt-get install -y rpm libsecret-1-dev pkg-config
- name: Install dependencies
run: npm ci
+29
View File
@@ -0,0 +1,29 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Bilingual README (English and Simplified Chinese)
- Project Logo
- GitHub Issue Templates (Bug Report and Feature Request)
- Code of Conduct
- Star History chart in README
- Detailed FAQ section in README
- Technical stack and project structure documentation
## [0.0.1] - 2025-12-22
### Added
- Initial release of Antigravity Manager
- Multi-account management for Google Gemini and Claude
- Real-time quota monitoring
- Intelligent auto-switching capabilities
- Secure credential storage (AES-256-GCM)
- IDE synchronization
- Dark mode support
- System tray integration
+48
View File
@@ -0,0 +1,48 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [INSERT EMAIL ADDRESS]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
---
**Note**: Please replace `[INSERT EMAIL ADDRESS]` with your actual contact email if you wish to receive reports.
+181
View File
@@ -0,0 +1,181 @@
# Contributing to Antigravity Manager
First off, thank you for considering contributing to Antigravity Manager! 🎉
It's people like you that make Antigravity Manager such a great tool. We welcome contributions from everyone, whether it's a bug report, feature suggestion, documentation improvement, or code contribution.
## 📋 Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Making Changes](#making-changes)
- [Pull Request Process](#pull-request-process)
- [Style Guidelines](#style-guidelines)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
## 📜 Code of Conduct
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
## 🚀 Getting Started
1. **Fork the repository** on GitHub
2. **Clone your fork** locally:
```bash
git clone https://github.com/YOUR_USERNAME/AntigravityManager.git
cd AntigravityManager
```
3. **Add the upstream remote**:
```bash
git remote add upstream https://github.com/Draculabo/AntigravityManager.git
```
## 💻 Development Setup
### Prerequisites
- Node.js v18 or higher
- npm (comes with Node.js)
- Git
### Installation
```bash
# Install dependencies
npm install
# Start development server
npm start
```
### Available Scripts
| Command | Description |
|---------|-------------|
| `npm start` | Start the app in development mode |
| `npm run lint` | Run ESLint to check for code issues |
| `npm run format:write` | Format code with Prettier |
| `npm run test:unit` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests |
| `npm run type-check` | Run TypeScript type checking |
| `npm run make` | Build production packages |
## ✏️ Making Changes
1. **Create a new branch** from `main`:
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
```
2. **Make your changes** and commit them:
```bash
git add .
git commit -m "feat: add amazing new feature"
```
3. **Keep your branch updated**:
```bash
git fetch upstream
git rebase upstream/main
```
### Commit Message Guidelines
We follow [Conventional Commits](https://www.conventionalcommits.org/). Each commit message should be structured as follows:
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
**Types:**
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation only changes
- `style`: Changes that don't affect code meaning (formatting, etc.)
- `refactor`: Code change that neither fixes a bug nor adds a feature
- `perf`: Performance improvement
- `test`: Adding or correcting tests
- `chore`: Changes to build process or auxiliary tools
**Examples:**
```
feat(auth): add Google OAuth support
fix(quota): resolve quota refresh timeout issue
docs(readme): update installation instructions
```
## 🔄 Pull Request Process
1. **Update documentation** if you're changing functionality
2. **Add tests** for new features
3. **Ensure all tests pass**: `npm run test:all`
4. **Ensure code is formatted**: `npm run format:write`
5. **Ensure no lint errors**: `npm run lint`
6. **Push to your fork** and create a Pull Request
### PR Checklist
- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or my feature works
- [ ] New and existing unit tests pass locally with my changes
## 🎨 Style Guidelines
### TypeScript/JavaScript
- Use TypeScript for all new code
- Follow the existing code style (enforced by ESLint and Prettier)
- Use meaningful variable and function names
- Add JSDoc comments for public APIs
### React Components
- Use functional components with hooks
- Keep components small and focused
- Use TypeScript interfaces for props
- Follow the existing file structure
### CSS/Styling
- Use TailwindCSS utility classes
- Follow the design system in `components.json`
- Ensure responsive design
## 🐛 Reporting Bugs
Found a bug? Please [create an issue](https://github.com/Draculabo/AntigravityManager/issues/new?template=bug_report.md) with:
- **Clear title** describing the issue
- **Steps to reproduce** the behavior
- **Expected behavior** vs actual behavior
- **Screenshots** if applicable
- **Environment details** (OS, app version)
## 💡 Suggesting Features
Have an idea? Please [create an issue](https://github.com/Draculabo/AntigravityManager/issues/new?template=feature_request.md) with:
- **Clear description** of the feature
- **Use case** - why is this feature needed?
- **Possible implementation** ideas (optional)
## 🙏 Thank You
Your contributions help make Antigravity Manager better for everyone. Thank you for taking the time to contribute!
---
If you have any questions, feel free to open an issue or reach out to the maintainers.
+262 -81
View File
@@ -1,121 +1,296 @@
# Antigravity Manager
<p align="center">
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
</p>
Antigravity Manager is a professional Electron-based application designed to manage multiple cloud AI accounts (Google Gemini / Claude) for the Antigravity application. It provides seamless account switching, real-time quota monitoring, intelligent auto-switching, and comprehensive backup management.
<h1 align="center">Antigravity Manager</h1>
## Features
<p align="center">
<strong>🚀 Professional multi-account manager for Google Gemini & Claude AI</strong>
</p>
### Core Features
<p align="center">
English | <a href="README.zh-CN.md">简体中文</a>
</p>
- **Cloud Account Pool Management**
- Add unlimited Google Gemini / Claude accounts via OAuth authorization
- Display account details: avatar, email, status, and last used time
- Real-time account status monitoring (Active, Rate Limited, Expired)
- Delete accounts from local database
<p align="center">
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/releases">
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/releases">
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
</a>
</p>
- **Real-time Quota Monitoring**
- Multi-model support: Monitor quotas for `gemini-pro`, `claude-3-5-sonnet`, etc.
- Visual display with progress bars and color indicators (green/yellow/red)
- Auto & manual refresh capabilities
<p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
</p>
- **Intelligent Auto-Switching**
- Unlimited pool mode: Automatically find and switch to the best backup account when current quota is low (<5%) or rate-limited
- Background monitoring: Built-in `CloudMonitorService` polls all accounts every 5 minutes
- Global toggle: Enable/disable via one-click in the UI
---
- **One-Click Account Switching**
- Inject tokens directly into IDE's `state.vscdb` database using Protobuf parsing
- Graceful process restart to apply new credentials
## 📖 Table of Contents
### Account Backup
- [Why Antigravity Manager?](#-why-antigravity-manager)
- [Features](#-features)
- [Screenshots](#-screenshots)
- [Quick Start](#-quick-start)
- [Tech Stack](#️-tech-stack)
- [Development](#-development)
- [FAQ](#-faq)
- [Contributing](#-contributing)
- [License](#-license)
- **Backup**: Capture snapshots of your current account state
- **Restore**: Fast and easy switching between multiple saved accounts
- **Management**: View, organize, and delete saved account snapshots
---
### Process Control
## ✨ Why Antigravity Manager?
- Automatically detect if Antigravity is running
- Launch Antigravity via URI protocol or executable path
- Gracefully close or force kill the Antigravity process
When using Antigravity IDE, have you ever encountered these problems?
### System Integration
- 😫 Single account quota runs out quickly, requiring frequent manual switching
- 🔄 Managing multiple Google/Claude accounts is cumbersome
- 📊 Don't know how much quota is left on the current account
- ⏰ Worried about missing quota reset times
- **System Tray**: Background mode with tray icon and right-click menu
- **IDE Sync**: Automatically scan and import accounts from IDE's `state.vscdb`
- **Batch Operations**: Batch refresh and delete multiple accounts
**Antigravity Manager** is here to solve these problems! It's a professional Electron desktop app that helps you:
### Security Hardening
- ✅ **Unlimited Account Pool** - Add any number of Google Gemini / Claude accounts
- ✅ **Smart Auto-Switching** - Automatically switch to the next available account when quota is low or rate-limited
- ✅ **Real-time Monitoring** - Visualize quota usage for all accounts
- ✅ **Secure Encryption** - AES-256-GCM encryption for sensitive data
- **Key Management**: Uses OS native credential manager (Windows Credential Manager / macOS Keychain) via `keytar` to securely store Master Key (AES-256)
- **Data Encryption**: All sensitive data (`token_json`, `quota_json`) encrypted with `AES-256-GCM` before storage
- **Auto Migration**: Automatic detection and migration of legacy plaintext data on startup
---
### Other Features
## 🎯 Features
- **Internationalization**: Multi-language support (English / 中文)
- **Modern UI**: Built with React, TailwindCSS, and Shadcn UI for a clean and responsive experience
<table>
<tr>
<td width="50%">
<h3>☁️ Cloud Account Pool</h3>
<ul>
<li>Add unlimited Google Gemini / Claude accounts via OAuth</li>
<li>Display avatar, email, status, and last used time</li>
<li>Real-time status monitoring (Active, Rate Limited, Expired)</li>
</ul>
</td>
<td width="50%">
<h3>📊 Real-time Quota Monitoring</h3>
<ul>
<li>Multi-model support: gemini-pro, claude-3-5-sonnet, etc.</li>
<li>Visual progress bars with color indicators</li>
<li>Auto & manual refresh capabilities</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔄 Intelligent Auto-Switching</h3>
<ul>
<li>Unlimited pool mode with smart backup selection</li>
<li>Auto-switch when quota < 5% or rate-limited</li>
<li>Background monitoring every 5 minutes</li>
</ul>
</td>
<td width="50%">
<h3>🔐 Security First</h3>
<ul>
<li>AES-256-GCM encryption for sensitive data</li>
<li>OS native credential manager integration</li>
<li>Auto migration of legacy plaintext data</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>💾 Account Backup</h3>
<ul>
<li>Capture snapshots of account state</li>
<li>Fast switching between saved accounts</li>
<li>View, organize, and delete snapshots</li>
</ul>
</td>
<td width="50%">
<h3>⚙️ Process Control</h3>
<ul>
<li>Auto-detect if Antigravity is running</li>
<li>Launch via URI protocol or executable</li>
<li>Graceful close or force kill</li>
</ul>
</td>
</tr>
</table>
## Tech Stack
### Additional Features
- **Core**: [Electron](https://www.electronjs.org/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/)
- **Build Tool**: [Vite](https://vitejs.dev/)
- **Styling**: [TailwindCSS](https://tailwindcss.com/)
- **State Management**: [TanStack Query](https://tanstack.com/query/latest)
- **Routing**: [TanStack Router](https://tanstack.com/router/latest)
- **Database**: [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3)
- **Testing**: [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/)
- **🖥️ System Tray** - Background mode with tray icon and right-click menu
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
- **📦 Batch Operations** - Batch refresh and delete multiple accounts
- **🌏 Internationalization** - Multi-language support (English / 中文)
- **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI
## Getting Started
---
### Prerequisites
## 📸 Screenshots
- Node.js (v18 or higher recommended)
<p align="center">
<img src="docs/assets/screenshot-main.png" alt="Main Interface" width="80%" />
</p>
---
## � Quick Start
### Download
Download the latest release for your platform from the [Releases](https://github.com/Draculabo/AntigravityManager/releases) page.
| Platform | Download |
|----------|----------|
| Windows | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
### Build from Source
#### Prerequisites
- Node.js v18 or higher
- npm or yarn
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd AntigravityManager
```
2. Install dependencies:
```bash
npm install
```
### Development
To start the application in development mode:
#### Steps
```bash
# Clone the repository
git clone https://github.com/Draculabo/AntigravityManager.git
cd AntigravityManager
# Install dependencies
npm install
# Start development
npm start
```
This will launch the Electron app with hot-reloading enabled.
### Building
To create a distributable package for your platform:
```bash
# Build for production
npm run make
```
The output will be available in the `out` directory.
---
### Testing
## �🛠️ Tech Stack
- Run unit tests: `npm run test:unit`
- Run E2E tests: `npm run test:e2e`
- Run all tests: `npm run test:all`
| Category | Technologies |
|----------|-------------|
| **Core** | [Electron](https://www.electronjs.org/), [React](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
| **Build Tool** | [Vite](https://vitejs.dev/) |
| **Styling** | [TailwindCSS](https://tailwindcss.com/), [Shadcn UI](https://ui.shadcn.com/) |
| **State** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
| **Database** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
| **Testing** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
## License
---
## � Development
### Available Scripts
| Command | Description |
|---------|-------------|
| `npm start` | Start the app in development mode |
| `npm run lint` | Run ESLint to check for code issues |
| `npm run format:write` | Format code with Prettier |
| `npm run test:unit` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests |
| `npm run type-check` | Run TypeScript type checking |
| `npm run make` | Build production packages |
### Project Structure
```
AntigravityManager/
├── src/
│ ├── main.ts # Electron main process
│ ├── preload.ts # Preload script
│ ├── renderer/ # React renderer process
│ ├── ipc/ # IPC communication handlers
│ └── server/ # Built-in server
├── docs/ # Documentation and assets
└── .github/ # GitHub configuration
```
---
## ❓ FAQ
<details>
<summary><b>Q: The app won't start?</b></summary>
Please check:
1. Make sure all dependencies are installed: `npm install`
2. Check if Node.js version is >= 18
3. Try deleting `node_modules` and reinstalling
</details>
<details>
<summary><b>Q: Account login failed?</b></summary>
1. Ensure network connection is working
2. Try clearing app data and logging in again
3. Check if the account is restricted by Google/Claude
</details>
<details>
<summary><b>Q: How to report issues or suggestions?</b></summary>
Please submit issues or suggestions via [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues).
</details>
---
## 🌟 Star History
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
</picture>
</a>
---
## 🤝 Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details.
We follow the [Contributor Covenant](CODE_OF_CONDUCT.md) Code of Conduct.
---
## 📄 License
[CC BY-NC-SA 4.0](LICENSE)
## Disclaimer
---
## ⚠️ Disclaimer
> [!WARNING]
> **For Educational Purposes Only**
@@ -123,3 +298,9 @@ The output will be available in the `out` directory.
> This project is intended solely for educational and research purposes. It is provided "as-is" without any warranty. **Commercial use is strictly prohibited.**
>
> By using this software, you agree that you will not use it for any commercial purposes, and you are solely responsible for ensuring your use complies with all applicable laws and regulations. The authors and contributors are not responsible for any misuse or damages arising from the use of this software.
---
<p align="center">
If this project helps you, please give it a ⭐ Star!
</p>
+306
View File
@@ -0,0 +1,306 @@
<p align="center">
<img src="docs/assets/logo.png" alt="Antigravity Manager" width="128" height="128" />
</p>
<h1 align="center">Antigravity Manager</h1>
<p align="center">
<strong>🚀 专业的 Google Gemini & Claude AI 多账号管理器</strong>
</p>
<p align="center">
<a href="README.md">English</a> | 简体中文
</p>
<p align="center">
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml">
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/testing.yaml/badge.svg" alt="Tests" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml">
<img src="https://github.com/Draculabo/AntigravityManager/actions/workflows/lint.yaml/badge.svg" alt="Lint" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/releases">
<img src="https://img.shields.io/github/v/release/Draculabo/AntigravityManager?style=flat-square" alt="Release" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/releases">
<img src="https://img.shields.io/github/downloads/Draculabo/AntigravityManager/total?style=flat-square&color=blue" alt="Downloads" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/Draculabo/AntigravityManager?style=flat-square" alt="License" />
</a>
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
<img src="https://img.shields.io/github/stars/Draculabo/AntigravityManager?style=flat-square" alt="Stars" />
</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/Electron-191970?style=for-the-badge&logo=Electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB" alt="React" />
<img src="https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
<img src="https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white" alt="TailwindCSS" />
</p>
---
## 📖 目录
- [为什么选择 Antigravity Manager?](#-为什么选择-antigravity-manager)
- [功能特性](#-功能特性)
- [截图预览](#-截图预览)
- [快速开始](#-快速开始)
- [技术栈](#️-技术栈)
- [开发指南](#-开发指南)
- [常见问题](#-常见问题)
- [贡献指南](#-贡献指南)
- [许可证](#-许可证)
---
## ✨ 为什么选择 Antigravity Manager?
在使用 Antigravity IDE 时,你是否遇到过这些问题?
- 😫 单个账号额度很快用完,需要频繁手动切换
- 🔄 管理多个 Google/Claude 账号非常麻烦
- 📊 不知道当前账号还剩多少额度
- ⏰ 担心错过额度重置时间
**Antigravity Manager** 就是为解决这些问题而生的!它是一个专业的 Electron 桌面应用,帮助你:
- ✅ **无限账号池** - 添加任意数量的 Google Gemini / Claude 账号
- ✅ **智能自动切换** - 额度不足或被限速时自动切换到下一个可用账号
- ✅ **实时监控** - 可视化显示所有账号的额度使用情况
- ✅ **安全加密** - AES-256-GCM 加密存储敏感信息
---
## 🎯 功能特性
<table>
<tr>
<td width="50%">
<h3>☁️ 云账号池管理</h3>
<ul>
<li>通过 OAuth 添加无限 Google Gemini / Claude 账号</li>
<li>显示头像、邮箱、状态和最后使用时间</li>
<li>实时状态监控(活跃、限速、过期)</li>
</ul>
</td>
<td width="50%">
<h3>📊 实时额度监控</h3>
<ul>
<li>多模型支持:gemini-pro、claude-3-5-sonnet 等</li>
<li>可视化进度条配色指示器</li>
<li>自动刷新与手动刷新</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔄 智能自动切换</h3>
<ul>
<li>无限池模式,智能选择备用账号</li>
<li>额度低于 5% 或被限速时自动切换</li>
<li>后台每 5 分钟自动监控</li>
</ul>
</td>
<td width="50%">
<h3>🔐 安全优先</h3>
<ul>
<li>AES-256-GCM 加密敏感数据</li>
<li>集成操作系统原生凭证管理器</li>
<li>自动迁移旧版明文数据</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>💾 账号备份</h3>
<ul>
<li>捕获账号状态快照</li>
<li>在已保存的账号间快速切换</li>
<li>查看、整理和删除快照</li>
</ul>
</td>
<td width="50%">
<h3>⚙️ 进程控制</h3>
<ul>
<li>自动检测 Antigravity 是否运行</li>
<li>通过 URI 协议或可执行文件启动</li>
<li>优雅关闭或强制终止</li>
</ul>
</td>
</tr>
</table>
### 更多功能
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
- **📦 批量操作** - 批量刷新和删除多个账号
- **🌏 国际化** - 多语言支持(English / 中文)
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
---
## 📸 截图预览
<p align="center">
<img src="docs/assets/screenshot-main.png" alt="主界面" width="80%" />
</p>
---
## 🚀 快速开始
### 下载安装
从 [Releases](https://github.com/Draculabo/AntigravityManager/releases) 页面下载适合你平台的最新版本。
| 平台 | 下载链接 |
|------|----------|
| Windows | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| macOS | [.dmg 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
| Linux | [.deb / .rpm](https://github.com/Draculabo/AntigravityManager/releases/latest) |
### 从源码构建
#### 前置要求
- Node.js v18 或更高版本
- npm 或 yarn
#### 步骤
```bash
# 克隆仓库
git clone https://github.com/Draculabo/AntigravityManager.git
cd AntigravityManager
# 安装依赖
npm install
# 启动开发模式
npm start
# 构建生产版本
npm run make
```
---
## 🛠️ 技术栈
| 类别 | 技术 |
|------|------|
| **核心** | [Electron](https://www.electronjs.org/)、[React](https://react.dev/)、[TypeScript](https://www.typescriptlang.org/) |
| **构建工具** | [Vite](https://vitejs.dev/) |
| **样式** | [TailwindCSS](https://tailwindcss.com/)、[Shadcn UI](https://ui.shadcn.com/) |
| **状态管理** | [TanStack Query](https://tanstack.com/query/latest)、[TanStack Router](https://tanstack.com/router/latest) |
| **数据库** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3) |
| **测试** | [Vitest](https://vitest.dev/)、[Playwright](https://playwright.dev/) |
---
## 💻 开发指南
### 可用脚本
| 命令 | 描述 |
|------|------|
| `npm start` | 启动开发模式 |
| `npm run lint` | 运行 ESLint 检查 |
| `npm run format:write` | 使用 Prettier 格式化代码 |
| `npm run test:unit` | 运行单元测试 |
| `npm run test:e2e` | 运行 E2E 测试 |
| `npm run test:all` | 运行所有测试 |
| `npm run type-check` | TypeScript 类型检查 |
| `npm run make` | 构建生产包 |
### 项目结构
```
AntigravityManager/
├── src/
│ ├── main.ts # Electron 主进程
│ ├── preload.ts # 预加载脚本
│ ├── renderer/ # React 渲染进程
│ ├── ipc/ # IPC 通信处理
│ └── server/ # 内置服务器
├── docs/ # 文档和资源
└── .github/ # GitHub 配置
```
---
## ❓ 常见问题
<details>
<summary><b>Q: 程序无法启动怎么办?</b></summary>
请检查:
1. 确保已安装所有依赖:`npm install`
2. 检查 Node.js 版本是否 >= 18
3. 尝试删除 `node_modules` 后重新安装
</details>
<details>
<summary><b>Q: 账号登录失败?</b></summary>
1. 确保网络连接正常
2. 尝试清除应用数据后重新登录
3. 检查账号是否被 Google/Claude 限制
</details>
<details>
<summary><b>Q: 如何反馈问题或建议?</b></summary>
请通过 [GitHub Issues](https://github.com/Draculabo/AntigravityManager/issues) 提交问题或建议。
</details>
---
## 🌟 Star History
<a href="https://github.com/Draculabo/AntigravityManager/stargazers">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Draculabo/AntigravityManager&type=Date" />
</picture>
</a>
---
## 🤝 贡献指南
欢迎贡献代码!请阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 了解详情。
我们遵循 [Contributor Covenant](CODE_OF_CONDUCT.md) 行为准则。
---
## 📄 许可证
[CC BY-NC-SA 4.0](LICENSE)
---
## ⚠️ 免责声明
> [!WARNING]
> **仅供学习研究使用**
>
> 本项目仅供教育和研究目的,按"原样"提供,不提供任何保证。**严禁商业使用。**
>
> 使用本软件即表示您同意不会将其用于任何商业目的,并自行负责确保您的使用符合所有适用法律法规。作者和贡献者对因使用本软件而产生的任何滥用或损害不承担责任。
---
<p align="center">
如果这个项目对你有帮助,请给一个 ⭐ Star 支持!
</p>
+93
View File
@@ -0,0 +1,93 @@
# Security Policy
## 🔒 Security Features
Antigravity Manager takes security seriously. Here are the security measures we've implemented:
### Encryption
- **AES-256-GCM** encryption for all sensitive data (`token_json`, `quota_json`)
- Unique initialization vectors (IV) for each encryption operation
- Authenticated encryption to prevent tampering
### Credential Storage
- **Windows**: Windows Credential Manager
- **macOS**: macOS Keychain
- **Linux**: Secret Service API / libsecret
Master keys are stored in the OS native credential manager via `keytar`, never in plain text files.
### Data Protection
- Sensitive tokens are encrypted before storage in SQLite database
- Automatic migration of legacy plaintext data on application startup
- No sensitive data is logged or transmitted to external services
## 📋 Supported Versions
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| < Latest | :x: |
We only provide security updates for the latest version. Please ensure you're running the most recent release.
## 🚨 Reporting a Vulnerability
We take all security vulnerabilities seriously. Thank you for helping to improve the security of Antigravity Manager.
### How to Report
**Please DO NOT report security vulnerabilities through public GitHub issues.**
Instead, please report them via one of the following methods:
1. **GitHub Security Advisories**: Use our [private vulnerability reporting](https://github.com/Draculabo/AntigravityManager/security/advisories/new)
2. **Email**: Send details to the project maintainer (see GitHub profile for contact)
### What to Include
Please include the following information in your report:
- Type of vulnerability (e.g., buffer overflow, SQL injection, XSS)
- Full paths of source file(s) related to the vulnerability
- Location of the affected source code (tag/branch/commit or direct URL)
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue and how an attacker might exploit it
### Response Timeline
- **Initial Response**: Within 48 hours
- **Status Update**: Within 5 business days
- **Resolution Timeline**: Depends on complexity, typically within 30 days
### What to Expect
1. **Acknowledgment**: We'll confirm receipt of your report
2. **Investigation**: We'll investigate and validate the vulnerability
3. **Fix Development**: We'll develop and test a fix
4. **Disclosure**: We'll coordinate disclosure timing with you
5. **Credit**: With your permission, we'll credit you in the release notes
## 🛡️ Security Best Practices for Users
To ensure the security of your data:
1. **Keep Updated**: Always use the latest version of Antigravity Manager
2. **System Security**: Keep your operating system updated
3. **Secure Environment**: Use the application on trusted devices only
4. **Backup**: Regularly backup your account data using the built-in backup feature
5. **Review Permissions**: Only grant necessary OAuth permissions
## 📜 Disclosure Policy
When we receive a security bug report, we will:
1. Confirm the problem and determine affected versions
2. Audit code to find any similar problems
3. Prepare fixes for all supported versions
4. Release new versions as soon as possible
Thank you for helping keep Antigravity Manager and its users safe!
Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

+14
View File
@@ -45,6 +45,20 @@ const config: ForgeConfig = {
for (const moduleName of nativeModules) {
copyModuleRecursive(moduleName);
}
// Copy assets to resources folder
const assetsSrc = path.join(process.cwd(), 'src', 'assets');
const assetsDest = path.join(buildPath, 'resources', 'assets');
if (fs.existsSync(assetsSrc)) {
if (!fs.existsSync(assetsDest)) {
fs.mkdirSync(assetsDest, { recursive: true });
}
fs.cpSync(assetsSrc, assetsDest, { recursive: true });
console.log(`Copied assets from ${assetsSrc} to ${assetsDest}`);
} else {
console.warn(`Assets directory not found: ${assetsSrc}`);
}
},
},
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

+3560 -55
View File
File diff suppressed because it is too large. Load diff
+20 -2
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.0.1",
"version": "0.1.0",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
@@ -36,10 +36,10 @@
"@electron-forge/shared-types": "^7.8.1",
"@electron/fuses": "~1.8.0",
"@electron/rebuild": "^3.7.1",
"node-addon-api": "^8.3.1",
"@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.0",
"@playwright/test": "^1.56.1",
"@swc/core": "^1.15.8",
"@tanstack/router-plugin": "^1.135.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -62,18 +62,30 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0",
"icon-gen": "^5.0.0",
"jsdom": "^27.1.0",
"node-addon-api": "^8.3.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"sharp": "^0.34.5",
"tailwindcss": "^4.1.16",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.3",
"unplugin-swc": "^1.5.9",
"vite": "^5.4.21",
"vitest": "^4.0.8"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@icons-pack/react-simple-icons": "^13.8.0",
"@nestjs/common": "^11.1.10",
"@nestjs/core": "^11.1.10",
"@nestjs/microservices": "^11.1.11",
"@nestjs/platform-express": "^11.1.11",
"@nestjs/platform-fastify": "^11.1.10",
"@nestjs/websockets": "^11.1.11",
"@orpc/client": "^1.10.4",
"@orpc/server": "^1.11.3",
"@radix-ui/react-avatar": "^1.1.11",
@@ -95,11 +107,15 @@
"@tanstack/react-router": "^1.139.3",
"@tanstack/react-router-devtools": "^1.135.0",
"@types/uuid": "^11.0.0",
"axios": "^1.13.2",
"better-sqlite3": "^12.4.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1",
"fastify": "^5.6.2",
"happy-dom": "^20.0.10",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
@@ -108,6 +124,8 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.2.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"undici": "^7.16.0",
+149
View File
@@ -0,0 +1,149 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Rocket } from 'lucide-react';
import sharp from 'sharp';
import icongen from 'icon-gen';
import path from 'path';
import fs from 'fs';
const IMAGES_DIR = path.join(process.cwd(), 'images');
// Colors
const BACKGROUND_COLOR = '#0f172a'; // slate-900 / primary
const ICON_COLOR = '#f8fafc'; // slate-50 / primary-foreground
const ICON_SIZE = 1024;
const PADDING = 0.25; // 25% padding
async function generate() {
console.log('Generating assets...');
// 1. Generate SVG String
// We want the icon to be centered and sized appropriately.
// The canvas is 1024x1024.
// We will create an SVG with the background rect and the icon centered.
// Lucide icons are 24x24 by default.
// We will scale it up.
const iconSize = ICON_SIZE * (1 - PADDING * 2);
const padding = ICON_SIZE * PADDING;
const iconSvg = renderToStaticMarkup(
React.createElement(Rocket, {
size: iconSize,
color: ICON_COLOR,
strokeWidth: 1.5, // Thicker stroke for larger icon? Or keep 2? 1.5 might look elegant.
}),
);
// Construct the full SVG
// We place the icon at (padding, padding)
const fullSvg = `
<svg width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 ${ICON_SIZE} ${ICON_SIZE}" xmlns="http://www.w3.org/2000/svg">
<rect width="${ICON_SIZE}" height="${ICON_SIZE}" fill="${BACKGROUND_COLOR}" />
<g transform="translate(${padding}, ${padding})">
${iconSvg}
</g>
</svg>
`;
// 2. Generate Base PNG
const pngBuffer = await sharp(Buffer.from(fullSvg)).png().toBuffer();
const iconPngPath = path.join(IMAGES_DIR, 'icon.png');
await sharp(pngBuffer).toFile(iconPngPath);
console.log(`Generated ${iconPngPath}`);
// 3. Generate ICO and ICNS using icon-gen
// icon-gen takes the png directory or file and outputs to destination
// Create a temporary directory for input if needed, but icon-gen works with a file path usually?
// Checking icon-gen docs or usage. commonly: icongen(input, destination, options)
// Input can be a path to a png image.
// Clean up old directories and files
const itemsToClean = [
'android',
'ios',
'icon.iconset',
'icon_master_squircle.png',
'storealogo.png',
'tray-icon.png', // incorrectly named
// Clean up "Square" logos as they appear unused
'Square30x30Logo.png',
'Square44x44Logo.png',
'Square71x71Logo.png',
'Square89x89Logo.png',
'Square107x107Logo.png',
'Square142x142Logo.png',
'Square150x150Logo.png',
'Square284x284Logo.png',
'Square310x310Logo.png',
'StoreLogo.png',
]; // checking casing or existence
for (const item of itemsToClean) {
const itemPath = path.join(IMAGES_DIR, item);
if (fs.existsSync(itemPath)) {
fs.rmSync(itemPath, { recursive: true, force: true });
console.log(`Removed old item: ${itemPath}`);
}
}
const options = {
report: true,
ico: {
name: 'icon',
sizes: [16, 24, 32, 48, 64, 128, 256],
},
icns: {
name: 'icon',
sizes: [16, 32, 64, 128, 256, 512, 1024],
},
favicon: {
name: 'favicon-',
pngSizes: [32, 57, 72, 96, 120, 128, 144, 152, 195, 228],
icoSizes: [16, 24, 32, 48, 64],
},
};
await icongen(iconPngPath, IMAGES_DIR, options);
console.log('Generated ICO, ICNS, and Favicon files');
// Generate standard PNGs only
const standardSizes = [
{ name: '32x32.png', size: 32 },
{ name: '64x64.png', size: 64 },
{ name: '128x128.png', size: 128 },
{ name: '[email protected]', size: 256 },
];
for (const { name, size } of standardSizes) {
await sharp(pngBuffer).resize(size, size).toFile(path.join(IMAGES_DIR, name));
console.log(`Generated ${name}`);
}
// Generate tray.png (Matched to code requirement)
const TRAY_SIZE = 24;
// Code uses 'tray.png'
const trayIconSvg = renderToStaticMarkup(
React.createElement(Rocket, {
size: TRAY_SIZE,
color: ICON_COLOR, // White
strokeWidth: 2,
}),
);
const trayPngPath = path.join(IMAGES_DIR, 'tray.png');
await sharp(Buffer.from(trayIconSvg)).png().toFile(trayPngPath);
console.log('Generated tray.png');
// Sync to src/assets
const SRC_ASSETS_DIR = path.join(process.cwd(), 'src', 'assets');
if (fs.existsSync(SRC_ASSETS_DIR)) {
fs.copyFileSync(iconPngPath, path.join(SRC_ASSETS_DIR, 'icon.png'));
fs.copyFileSync(trayPngPath, path.join(SRC_ASSETS_DIR, 'tray.png'));
console.log('Synced icon.png and tray.png to src/assets/');
}
}
generate().catch(console.error);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 659 B

-185
View File
@@ -1,185 +0,0 @@
export enum ResponseType {
None = 0,
Thinking = 1,
Text = 2,
Tool = 3,
}
export interface StreamEvent {
event: string;
data: string;
}
export class ClaudeStreamConverter {
public responseIndex: number = 0;
public currentType: ResponseType = ResponseType.None;
public hasContent: boolean = false;
constructor() {}
processChunk(jsonChunk: any): StreamEvent[] {
const events: StreamEvent[] = [];
// 0. Handle Prompt Feedback (Blocked requests)
if (!jsonChunk.candidates && !jsonChunk.choices) {
if (jsonChunk.promptFeedback) {
console.warn(
`(Anthropic) Received promptFeedback (likely blocked): ${JSON.stringify(jsonChunk.promptFeedback)}`,
);
}
return events;
}
const choices = jsonChunk.choices;
if (!choices || choices.length === 0) return events;
const choice = choices[0];
const delta = choice.delta;
const deltaContent = delta.content || delta.text || ''; // Support both content and text fields just in case
const isThought = delta.thought === true || !!delta.thinking; // Check for thinking flag or content
const thinkingContent = delta.thinking || '';
const thoughtSignature = delta.thoughtSignature;
// If we have content of any kind
if (deltaContent || isThought || thoughtSignature || thinkingContent) {
this.hasContent = true;
}
// 1. Handle Thinking
// If it is explicitly marked as thought, or has thinking content
if (isThought || thoughtSignature || thinkingContent) {
// Close Text if open
if (this.currentType === ResponseType.Text) {
events.push(
this.createEvent('content_block_stop', {
type: 'content_block_stop',
index: this.responseIndex,
}),
);
this.responseIndex++;
this.currentType = ResponseType.None;
}
// Open Thinking if not open
if (this.currentType === ResponseType.None) {
events.push(
this.createEvent('content_block_start', {
type: 'content_block_start',
index: this.responseIndex,
content_block: { type: 'thinking', thinking: '' }, // Anthropic Beta expects 'thinking' block
}),
);
this.currentType = ResponseType.Thinking;
}
if (thinkingContent) {
events.push(
this.createEvent('content_block_delta', {
type: 'content_block_delta',
index: this.responseIndex,
delta: { type: 'thinking_delta', thinking: thinkingContent },
}),
);
}
}
// 2. Handle Regular Text
else if (deltaContent) {
// Close Thinking if open
if (this.currentType === ResponseType.Thinking) {
events.push(
this.createEvent('content_block_stop', {
type: 'content_block_stop',
index: this.responseIndex,
}),
);
this.responseIndex++;
this.currentType = ResponseType.None;
}
// Open Text if not open
if (this.currentType === ResponseType.None) {
events.push(
this.createEvent('content_block_start', {
type: 'content_block_start',
index: this.responseIndex,
content_block: { type: 'text', text: '' },
}),
);
this.currentType = ResponseType.Text;
}
events.push(
this.createEvent('content_block_delta', {
type: 'content_block_delta',
index: this.responseIndex,
delta: { type: 'text_delta', text: deltaContent },
}),
);
}
// 3. Handle Stop Reason
if (choice.finish_reason) {
// Close any open block
if (this.currentType !== ResponseType.None) {
events.push(
this.createEvent('content_block_stop', {
type: 'content_block_stop',
index: this.responseIndex,
}),
);
this.responseIndex++;
this.currentType = ResponseType.None;
}
let stopReason = 'end_turn';
const r = choice.finish_reason;
if (r === 'length' || r === 'MAX_TOKENS') stopReason = 'max_tokens';
else if (r === 'tool_calls') stopReason = 'tool_use';
// Safety mappings
else if (r === 'SAFETY' || r === 'RECITATION') stopReason = 'end_turn'; // Fallback
events.push(
this.createEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: 0 },
}),
);
events.push(
this.createEvent('message_stop', {
type: 'message_stop',
}),
);
}
return events;
}
static createMessageStart(msgId: string, model: string): StreamEvent {
return {
event: 'message_start',
data: JSON.stringify({
type: 'message_start',
message: {
id: msgId,
type: 'message',
role: 'assistant',
model: model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
}),
};
}
createEvent(name: string, data: any): StreamEvent {
return {
event: name,
data: JSON.stringify(data),
};
}
}
-207
View File
@@ -1,207 +0,0 @@
import {
OpenAIChatRequest,
OpenAIMessage,
GeminiContent,
GeminiPart,
GeminiInlineData,
AnthropicChatRequest,
AnthropicMessage,
AnthropicContent,
ContentPart,
} from './types';
import { logger } from '../../utils/logger';
// Helper to extract text from content
function getTextFromContent(content: string | ContentPart[]): string {
if (typeof content === 'string') return content;
return content
.map((p) => {
if (p.type === 'text') return p.text || '';
return '';
})
.join('');
}
function getPreview(content: string | ContentPart[]): string {
const text = getTextFromContent(content);
return text.length > 200 ? text.substring(0, 200) + '...' : text;
}
export const Converter = {
/**
* Convert OpenAI messages to Gemini contents
*/
convertOpenAIToGeminiContents(messages: OpenAIMessage[]): GeminiContent[] {
const contents: GeminiContent[] = [];
const pendingImages: GeminiInlineData[] = [];
// Regex for markdown images: ![alt](data:image/png;base64,...)
// Group 1: mime, Group 2: data
const reMarkdown =
/!\[.*?\]\(data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)\)/g;
// Regex for explicit data URLs
const reDataUrl = /data:\s*(image\/[a-zA-Z+-]+)\s*;\s*base64\s*,\s*([a-zA-Z0-9+/=\s]+)/;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
// logger.info(`Msg[${i}][${msg.role}] content=${getPreview(msg.content)}`);
const role = msg.role === 'assistant' ? 'model' : msg.role === 'system' ? 'user' : msg.role;
const parts: GeminiPart[] = [];
// 1. Inject Pending Images from Assistant history to User
if (role === 'user' && pendingImages.length > 0) {
logger.info(`Injecting ${pendingImages.length} pending images to User message`);
while (pendingImages.length > 0) {
const img = pendingImages.shift();
if (img) parts.push({ inlineData: img });
}
}
// 2. Parse Content
if (typeof msg.content === 'string') {
const text = msg.content;
let lastEnd = 0;
let match;
// Reset regex state
reMarkdown.lastIndex = 0;
while ((match = reMarkdown.exec(text)) !== null) {
const matchStart = match.index;
const matchEnd = reMarkdown.lastIndex;
if (matchStart > lastEnd) {
const textPart = text.substring(lastEnd, matchStart);
if (textPart) parts.push({ text: textPart });
}
const mimeType = match[1];
const data = match[2].replace(/\s/g, '');
const inlineData = { mimeType, data };
if (role === 'model') {
pendingImages.push(inlineData);
} else {
parts.push({ inlineData });
}
lastEnd = matchEnd;
}
if (lastEnd < text.length) {
const textPart = text.substring(lastEnd);
if (textPart) parts.push({ text: textPart });
}
} else if (Array.isArray(msg.content)) {
// Array format
for (const part of msg.content) {
if (part.type === 'text') {
if (part.text) parts.push({ text: part.text });
} else if (part.type === 'image_url' && part.image_url) {
const url = part.image_url.url;
const validMatch = url.match(reDataUrl);
if (validMatch) {
const mimeType = validMatch[1];
const data = validMatch[2].replace(/\s/g, '');
const inlineData = { mimeType, data };
if (role === 'model') {
pendingImages.push(inlineData);
} else {
parts.push({ inlineData });
}
} else {
logger.warn(`Ignoring unsupported image URL format`);
}
}
}
}
// 3. Fallbacks
if (role === 'model' && parts.length === 0 && pendingImages.length > 0) {
// Was likely just an image in assistant history, we moved it to pending.
// But model message cannot be empty?
// Actually the loop above pushes for 'model' into pendingImages ONLY if it matches helper logic.
// If the loops finishes and we have parts, good.
// If empty and we put stuff in pendingImages, we need a placeholder?
parts.push({ text: '[Image Generated]' });
}
if (parts.length === 0) {
parts.push({ text: '' });
}
contents.push({ role, parts });
}
// Merge User Messages
return this.mergeConsecutiveUserMessages(contents);
},
convertAnthropicToGeminiContents(
request: AnthropicChatRequest,
signatureMap?: Map<string, string>,
): GeminiContent[] {
const contents: GeminiContent[] = [];
for (const msg of request.messages) {
const role = msg.role === 'assistant' ? 'model' : 'user';
const parts: GeminiPart[] = [];
const msgContent = Array.isArray(msg.content)
? msg.content
: [{ type: 'text', text: msg.content } as AnthropicContent];
for (const content of msgContent) {
if (content.type === 'text' && content.text) {
parts.push({ text: content.text });
} else if (content.type === 'image' && content.source && content.source.type === 'base64') {
parts.push({
inlineData: {
mimeType: content.source.media_type,
data: content.source.data,
},
});
} else if (content.type === 'thinking') {
// Ignore thinking input
}
}
// Thought Signature Injection (for Assistant)
if (role === 'model' && signatureMap) {
const latest = signatureMap.get('latest');
if (latest) {
parts.push({ thoughtSignature: latest });
}
}
contents.push({ role, parts });
}
return this.mergeConsecutiveUserMessages(contents);
},
mergeConsecutiveUserMessages(contents: GeminiContent[]): GeminiContent[] {
let i = 1;
while (i < contents.length) {
if (contents[i].role === 'user' && contents[i - 1].role === 'user') {
const prev = contents[i - 1];
const curr = contents[i];
// Add separator if needed
const prevLast = prev.parts[prev.parts.length - 1];
const currFirst = curr.parts[0];
if (prevLast && prevLast.text !== undefined && currFirst && currFirst.text !== undefined) {
prev.parts.push({ text: '\n\n' });
}
prev.parts.push(...curr.parts);
contents.splice(i, 1);
} else {
i++;
}
}
return contents;
},
};
-229
View File
@@ -1,229 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { OpenAIChatRequest, AnthropicChatRequest, GeminiContent } from './types';
import { Converter } from './converter';
import { logger } from '../../utils/logger';
import { ProxyAgent } from 'undici';
import { UpstreamProxyConfig } from '../../types/config';
// Type definitions for fetch (since we are in Node)
// Electron main process has 'fetch' globally available in newer versions (Node 18+),
// but we might need to handle types.
declare const fetch: any;
export class GeminiClient {
private timeoutMs: number;
private upstreamProxy?: UpstreamProxyConfig;
constructor(timeoutSeconds: number = 60, upstreamProxy?: UpstreamProxyConfig) {
this.timeoutMs = timeoutSeconds * 1000;
this.upstreamProxy = upstreamProxy;
}
private getFetchOptions() {
if (this.upstreamProxy?.enabled && this.upstreamProxy.url) {
return {
dispatcher: new ProxyAgent(this.upstreamProxy.url),
};
}
return {};
}
async streamGenerateAnthropic(
request: AnthropicChatRequest,
accessToken: string,
projectId: string,
sessionId: string,
signatureMap?: Map<string, string>,
modelMapping?: Record<string, string>,
): Promise<any> {
// Returns a ReadableStream or similar
const url =
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse';
const contents = Converter.convertAnthropicToGeminiContents(request, signatureMap);
// ... (System prompt handling remains same)
const systemInstruction = request.system
? {
role: 'user',
parts: [
{
text:
typeof request.system === 'string'
? request.system
: JSON.stringify(request.system),
},
],
}
: { role: 'user', parts: [{ text: '' }] };
const generationConfig: any = {
temperature: request.temperature ?? 1.0,
topP: request.top_p ?? 0.95,
maxOutputTokens: request.max_tokens ?? 16384,
candidateCount: 1,
};
if (request.model.includes('sonnet') || request.model.includes('thinking')) {
generationConfig.thinkingConfig = {
includeThoughts: true,
thinkingBudget: 8191,
};
}
let upstreamModel = request.model;
// 1. User Custom Mapping (Fuzzy match)
if (modelMapping) {
for (const [key, value] of Object.entries(modelMapping)) {
if (request.model.includes(key)) {
upstreamModel = value;
break;
}
}
}
// 2. Built-in Fallback Logic
const lowerName = upstreamModel.toLowerCase();
if (!upstreamModel.startsWith('gemini')) {
// Only map if not already a gemini model (roughly)
if (lowerName.includes('sonnet') || lowerName.includes('thinking')) {
upstreamModel = 'gemini-3-pro-preview';
} else if (lowerName.includes('haiku')) {
upstreamModel = 'gemini-2.0-flash-exp';
} else if (lowerName.includes('opus')) {
upstreamModel = 'gemini-3-pro-preview';
} else if (lowerName.includes('claude')) {
upstreamModel = 'gemini-2.5-flash-thinking';
} else if (lowerName === 'gemini-3-pro-high' || lowerName === 'gemini-3-pro-low') {
upstreamModel = 'gemini-3-pro-preview';
} else if (lowerName === 'gemini-3-flash') {
upstreamModel = 'gemini-3-flash-preview';
}
}
const requestBody = {
project: projectId,
requestId: uuidv4(),
model: upstreamModel,
userAgent: 'antigravity',
request: {
contents,
systemInstruction,
generationConfig,
sessionId,
},
};
logger.debug(
`(Gateway) Anthropic Request: ${request.model} -> ${upstreamModel} | Proj: ${projectId}`,
);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
'User-Agent': 'claude-cli/1.0.83 (external, cli)',
'X-App': 'cli',
'Anthropic-Beta': 'claude-code-20250219,interleaved-thinking-2025-05-14',
'X-Stainless-Lang': 'js',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
...this.getFetchOptions(),
});
clearTimeout(timeout);
if (!response.ok) {
const text = await response.text();
throw new Error(`Upstream Error ${response.status}: ${text}`);
}
return response.body; // Node.js Readable Web Stream
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
async generate(
request: OpenAIChatRequest,
accessToken: string,
projectId: string,
sessionId: string,
): Promise<any> {
const url = 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent';
// Split System
const systemMessages = request.messages.filter((m) => m.role === 'system');
const chatMessages = request.messages.filter((m) => m.role !== 'system');
const systemText = systemMessages
.map((m) => {
if (typeof m.content === 'string') return m.content;
return '';
})
.join('\n');
const contents = Converter.convertOpenAIToGeminiContents(chatMessages);
const generationConfig = {
temperature: request.temperature ?? 1.0,
topP: request.top_p ?? 0.95,
maxOutputTokens: request.max_tokens ?? 8096,
candidateCount: 1,
};
const requestBody = {
project: projectId,
requestId: uuidv4(),
model: request.model,
userAgent: 'antigravity',
request: {
contents,
systemInstruction: {
role: 'user',
parts: [{ text: systemText }],
},
generationConfig,
toolConfig: { functionCallingConfig: { mode: 'VALIDATED' } },
sessionId,
},
};
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
Host: 'daily-cloudcode-pa.sandbox.googleapis.com',
'User-Agent': 'antigravity/1.11.3 windows/amd64',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
...this.getFetchOptions(),
});
clearTimeout(timeout);
if (!response.ok) {
const text = await response.text();
throw new Error(`Upstream Error ${response.status}: ${text}`);
}
return await response.json();
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
}
+15 -13
View File
@@ -1,21 +1,24 @@
/**
* Gateway IPC Handlers
* Provides ORPC handlers for controlling the API Gateway service
* Provides ORPC handlers for controlling the API Gateway service (NestJS version)
*/
import { gatewayInstance } from './server';
import { bootstrapNestServer, stopNestServer, getNestServerStatus } from '../../server/main';
import { ConfigManager } from '../config/manager';
import { v4 as uuidv4 } from 'uuid';
/**
* Start the gateway server
* Start the gateway server (NestJS)
*/
export const startGateway = async (port: number): Promise<boolean> => {
try {
if (gatewayInstance.isRunning()) {
await gatewayInstance.stop();
}
await gatewayInstance.start(port);
return true;
// Stop if already running
await stopNestServer();
// Load full config and start NestJS server
const config = ConfigManager.loadConfig();
const proxyConfig = { ...config.proxy, port };
return await bootstrapNestServer(proxyConfig);
} catch (e) {
console.error('Failed to start gateway:', e);
return false;
@@ -23,12 +26,11 @@ export const startGateway = async (port: number): Promise<boolean> => {
};
/**
* Stop the gateway server
* Stop the gateway server (NestJS)
*/
export const stopGateway = async (): Promise<boolean> => {
try {
await gatewayInstance.stop();
return true;
return await stopNestServer();
} catch (e) {
console.error('Failed to stop gateway:', e);
return false;
@@ -36,10 +38,10 @@ export const stopGateway = async (): Promise<boolean> => {
};
/**
* Get gateway status
* Get gateway status (NestJS)
*/
export const getGatewayStatus = async () => {
return gatewayInstance.getStatus();
return getNestServerStatus();
};
/**
-267
View File
@@ -1,267 +0,0 @@
import http from 'http';
import { TokenManager } from './tokenManager';
import { GeminiClient } from './geminiClient';
import { ClaudeStreamConverter } from './claudeConverter';
import { logger } from '../../utils/logger';
import { Readable } from 'stream';
import { ConfigManager } from '../config/manager';
import { v4 as uuidv4 } from 'uuid';
const PORT = 3000; // Default, make configurable?
export class GatewayServer {
private server: http.Server | null = null;
private tokenManager: TokenManager;
private client: GeminiClient;
private signatureMap: Map<string, string> = new Map();
private currentPort: number = 0;
constructor() {
this.tokenManager = new TokenManager();
this.client = new GeminiClient(60);
}
async start(port: number = PORT) {
// Load config and accounts
const config = ConfigManager.loadConfig();
await this.tokenManager.loadAccounts();
logger.info(`Gateway: Loaded ${await this.tokenManager.loadAccounts()} accounts`);
// Initialize Client with config
// Default to 60s if not set, pass upstream proxy config
this.client = new GeminiClient(
config.proxy?.request_timeout || 60,
config.proxy?.upstream_proxy,
);
this.server = http.createServer(async (req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host}`);
try {
if (url.pathname === '/v1/messages' && req.method === 'POST') {
await this.handleAnthropicMessages(req, res);
} else if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
// await this.handleChatCompletions(req, res);
res.writeHead(501);
res.end('Not Implemented Yet');
} else if (url.pathname === '/healthz') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} else {
res.writeHead(404);
res.end('Not Found');
}
} catch (e) {
logger.error('Gateway: Request Error', e);
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(e) }));
}
}
});
this.server.on('error', (e: any) => {
if (e.code === 'EADDRINUSE') {
logger.error(`Gateway: Port ${port} is already in use.`);
} else {
logger.error('Gateway: Server error', e);
}
});
this.server.listen(port, () => {
this.currentPort = port;
logger.info(`Gateway: Server listening on http://127.0.0.1:${port}`);
});
}
private async handleAnthropicMessages(req: http.IncomingMessage, res: http.ServerResponse) {
const body = await this.readBody(req);
const request = JSON.parse(body);
logger.info(`(Anthropic) Request ${request.model}`);
// Retry Loop
const maxRetries = 3; // or tokenManager count
let attempts = 0;
while (attempts < maxRetries) {
attempts++;
const token = await this.tokenManager.getToken();
if (!token) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({ error: { type: 'overloaded_error', message: 'No accounts available' } }),
);
return;
}
logger.info(`(Anthropic) Using account ${token.email} (Attempt ${attempts})`);
try {
const config = ConfigManager.loadConfig();
const mapping = config.proxy?.anthropic_mapping || {};
const stream = await this.client.streamGenerateAnthropic(
request,
token.access_token,
token.project_id || 'unknown-project',
token.session_id,
this.signatureMap,
mapping,
);
// Handle SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const msgId = `msg_${uuidv4()}`;
const startEvent = ClaudeStreamConverter.createMessageStart(msgId, request.model);
res.write(`event: ${startEvent.event}\ndata: ${startEvent.data}\n\n`);
const converter = new ClaudeStreamConverter();
// Read the stream
// Node fetch response.body is a ReadableStream (web stream) or IncomingMessage (Node stream) depending on fetch polyfill
// Assuming it's an async iterable or we can get a reader.
// In Node 18+ native fetch, body is a Web stream.
for await (const chunk of stream) {
// chunk is Buffer or Uint8Array
const text = Buffer.from(chunk).toString('utf-8');
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
// Logic from server.rs: process_chunk -> get events
const events = converter.processChunk(json);
for (const event of events) {
res.write(`event: ${event.event}\ndata: ${event.data}\n\n`);
}
} catch (e) {
logger.warn('Gateway: Failed to parse Gemini chunk', e);
}
}
}
}
res.end();
return; // Success
} catch (e: any) {
logger.error(`(Anthropic) Request failed: ${e.message}`);
// Check retry
if (this.shouldRetry(e.message) && attempts < maxRetries) {
logger.warn(`(Anthropic) Retrying...`);
continue;
}
// Final error
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { type: 'api_error', message: e.message } }));
}
return;
}
}
}
private shouldRetry(errorMsg: string): boolean {
// Simple heuristic
if (
errorMsg.includes('429') ||
errorMsg.includes('503') ||
errorMsg.includes('500') ||
errorMsg.includes('403')
)
return true;
return false;
}
private readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => resolve(body));
req.on('error', reject);
});
}
// ===== Lifecycle & Status Methods =====
/**
* Stop the gateway server
*/
stop(): Promise<void> {
return new Promise((resolve, reject) => {
if (!this.server) {
resolve();
return;
}
this.server.close((err) => {
if (err) {
logger.error('Gateway: Failed to stop server', err);
reject(err);
} else {
logger.info('Gateway: Server stopped');
this.server = null;
this.currentPort = 0;
resolve();
}
});
});
}
/**
* Check if the server is running
*/
isRunning(): boolean {
return this.server !== null && this.server.listening;
}
/**
* Get current gateway status
*/
async getStatus(): Promise<{
running: boolean;
port: number;
base_url: string;
active_accounts: number;
}> {
const accountCount = await this.getAccountCount();
return {
running: this.isRunning(),
port: this.currentPort,
base_url: this.isRunning() ? `http://localhost:${this.currentPort}` : '',
active_accounts: accountCount,
};
}
/**
* Get the number of loaded accounts
*/
async getAccountCount(): Promise<number> {
return this.tokenManager.loadAccounts();
}
}
// Singleton instance for global access
export const gatewayInstance = new GatewayServer();
-141
View File
@@ -1,141 +0,0 @@
import { ProxyToken } from './types';
import { logger } from '../../utils/logger';
import { GoogleAPIService } from '../../services/GoogleAPIService';
import { CloudAccountRepo } from '../database/cloudHandler';
import { CloudAccount } from '../../types/cloudAccount';
export class TokenManager {
private tokens: Map<string, ProxyToken> = new Map();
private currentIndex: number = 0;
constructor() {}
async loadAccounts(): Promise<number> {
// Load from SQLite DB
// Fix: Use getAccounts() instead of list()
const accounts = await CloudAccountRepo.getAccounts();
let count = 0;
for (const account of accounts) {
try {
const token = this.convertAccountToToken(account);
if (token) {
this.tokens.set(token.account_id, token);
count++;
}
} catch (e) {
logger.warn(`Gateway: Failed to load account ${account.email}`, e);
}
}
return count;
}
private convertAccountToToken(account: CloudAccount): ProxyToken | null {
// Fix: account.token instead of account.token_data
if (!account.token) return null;
return {
account_id: account.id,
email: account.email,
access_token: account.token.access_token,
refresh_token: account.token.refresh_token,
expires_in: account.token.expires_in,
timestamp: account.token.expiry_timestamp,
account_path: '', // Not used with DB
project_id: account.token.project_id || undefined,
session_id: account.token.session_id || this.generateSessionId(),
};
}
generateSessionId(): string {
const min = 1_000_000_000_000_000_000n;
const max = 9_000_000_000_000_000_000n;
const range = max - min;
const rand = BigInt(Math.floor(Math.random() * Number(range)));
return (-(min + rand)).toString();
}
async getToken(): Promise<ProxyToken | null> {
if (this.tokens.size === 0) {
await this.loadAccounts();
}
if (this.tokens.size === 0) return null;
const keys = Array.from(this.tokens.keys());
this.currentIndex = (this.currentIndex + 1) % keys.length;
const key = keys[this.currentIndex];
const token = this.tokens.get(key);
if (!token) return null;
const now = Math.floor(Date.now() / 1000);
if (now >= token.timestamp - 300) {
logger.info(`Gateway: Token for ${token.email} expiring, refreshing...`);
try {
const newTokens = await GoogleAPIService.refreshAccessToken(token.refresh_token);
// Update Token Object
token.access_token = newTokens.access_token;
token.expires_in = newTokens.expires_in;
token.timestamp = Math.floor(Date.now() / 1000) + newTokens.expires_in;
// Update DB
await this.saveRefreshedToken(token.account_id, token);
this.tokens.set(token.account_id, token);
logger.info(`Gateway: Token refreshed for ${token.email}`);
} catch (e) {
logger.error(`Gateway: Failed to refresh token for ${token.email}`, e);
}
}
if (!token.project_id) {
try {
const mockId = `cloud-code-${Math.floor(Math.random() * 100000)}`;
token.project_id = mockId;
await this.saveProjectId(token.account_id, mockId);
} catch (e) {
logger.warn('Gateway: Failed to resolve project ID', e);
}
}
return token;
}
async saveRefreshedToken(accountId: string, token: ProxyToken) {
try {
const acc = await CloudAccountRepo.getAccount(accountId);
if (acc && acc.token) {
// Update token fields
const newToken = {
...acc.token,
access_token: token.access_token,
expires_in: token.expires_in,
expiry_timestamp: token.timestamp,
};
// Use updateToken which expects the whole token object (or partial?)
// CloudHandler.updateToken expects JSON object that it stringifies.
await CloudAccountRepo.updateToken(accountId, newToken);
}
} catch (e) {
logger.error(`Gateway: Failed to save refreshed token to DB`, e);
}
}
async saveProjectId(accountId: string, projectId: string) {
try {
// CloudAccountRepo.getAccount is async
const acc = await CloudAccountRepo.getAccount(accountId);
if (acc && acc.token) {
const newToken = {
...acc.token,
project_id: projectId,
};
await CloudAccountRepo.updateToken(accountId, newToken);
}
} catch (e) {
logger.error(`Gateway: Failed to save project ID to DB`, e);
}
}
}
-86
View File
@@ -1,86 +0,0 @@
export interface ProxyToken {
account_id: string;
access_token: string;
refresh_token: string;
expires_in: number;
timestamp: number;
email: string;
account_path: string;
project_id?: string;
session_id: string;
}
// OpenAI Types
export interface OpenAIMessage {
role: string;
content: string | ContentPart[];
name?: string;
}
export interface ContentPart {
type: 'text' | 'image_url';
text?: string;
image_url?: { url: string };
}
export interface OpenAIChatRequest {
model: string;
messages: OpenAIMessage[];
temperature?: number;
top_p?: number;
max_tokens?: number;
stream?: boolean;
size?: string;
quality?: string;
user?: string;
[key: string]: any; // Extra params
}
// Anthropic Types
export interface AnthropicContent {
type: 'text' | 'image' | 'thinking';
text?: string; // for text or thinking
thinking?: string;
signature?: string;
source?: {
type: 'base64';
media_type: string;
data: string;
};
}
export interface AnthropicMessage {
role: string;
content: AnthropicContent[] | string; // Can be string in some clients
thought_signature?: string;
}
export interface AnthropicChatRequest {
model: string;
messages: AnthropicMessage[];
system?: string | any[]; // System prompt
max_tokens?: number;
metadata?: any;
stop_sequences?: string[];
stream?: boolean;
temperature?: number;
top_p?: number;
top_k?: number;
}
// Gemini Types
export interface GeminiInlineData {
mimeType: string;
data: string;
}
export interface GeminiPart {
text?: string;
inlineData?: GeminiInlineData;
thoughtSignature?: string;
}
export interface GeminiContent {
role: string;
parts: GeminiPart[];
}
+2
View File
@@ -10,6 +10,7 @@ import { gatewayRouter } from './gateway/router';
import { os } from '@orpc/server';
import { z } from 'zod';
import { isProcessRunning, closeAntigravity, startAntigravity } from './process/handler';
import { systemHandler } from './system/handler';
// Log middleware setup
const logMiddleware = os.middleware(async (opts: any) => {
@@ -51,4 +52,5 @@ export const router = os.use(logMiddleware).router({
cloud: cloudRouter,
config: configRouter,
gateway: gatewayRouter,
system: systemHandler,
});
+64
View File
@@ -0,0 +1,64 @@
import { os } from '@orpc/server';
import { z } from 'zod';
import { networkInterfaces } from 'os';
// Schema for IP info
const IpInfoSchema = z.object({
address: z.string(),
name: z.string(),
isRecommended: z.boolean(),
});
export const systemHandler = os.router({
// Get all available local IPs with their adapter names
get_local_ips: os.output(z.array(IpInfoSchema)).handler(async () => {
const nets = networkInterfaces();
const results: { address: string; name: string; isRecommended: boolean }[] = [];
for (const name of Object.keys(nets)) {
for (const net of nets[name] || []) {
const family = net.family;
const isIPv4 = family === 'IPv4';
if (!isIPv4 || net.internal) {
continue;
}
const addr = net.address;
// Skip non-LAN addresses
if (addr.startsWith('169.254.')) continue; // APIPA
if (addr.startsWith('198.18.')) continue; // CGNAT / VPN
// Determine if this is a recommended (likely real LAN) address
let isRecommended = false;
const lowerName = name.toLowerCase();
if (addr.startsWith('192.168.') || addr.startsWith('10.')) {
if (
lowerName.includes('wlan') ||
lowerName.includes('wi-fi') ||
lowerName.includes('wireless') ||
lowerName === '以太网' ||
lowerName === 'ethernet' ||
lowerName.match(/^eth\d/)
) {
isRecommended = true;
}
}
results.push({ address: addr, name, isRecommended });
}
}
// Sort: recommended first, then by address
results.sort((a, b) => {
if (a.isRecommended !== b.isRecommended) {
return b.isRecommended ? 1 : -1;
}
return a.address.localeCompare(b.address);
});
return results;
}),
});
+587
View File
@@ -0,0 +1,587 @@
import { v4 as uuidv4 } from 'uuid';
import { mapClaudeModelToGemini } from './ModelMapping';
import { cleanJsonSchema } from './JsonSchemaUtils';
import { SignatureStore } from './SignatureStore';
import {
ClaudeRequest,
Message,
Tool,
GeminiInternalRequest,
GeminiContent,
GeminiToolDeclaration,
GenerationConfig,
ImageConfig,
FunctionDeclaration,
SafetySetting,
} from './types';
/**
* Request Configuration
* Contains request type, model, and image generation configuration
*/
interface RequestConfig {
/** Request type: 'agent', 'web_search', 'image_gen' */
requestType: string;
/** Whether to inject Google Search tool */
injectGoogleSearch: boolean;
/** Final model name to use */
finalModel: string;
/** Image generation config (only for image generation requests) */
imageConfig: ImageConfig | null;
}
// --- Main Logic ---
// --- Main Logic ---
/**
* Transforms Claude request into Gemini internal request format
* @param claudeReq Claude API request
* @param projectId Gemini Project ID
* @returns Gemini internal request format
*/
export function transformClaudeRequestIn(
claudeReq: ClaudeRequest,
projectId: string,
): GeminiInternalRequest {
// Check for networking tools (server tool or built-in tool)
const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools);
// Map to store tool_use id -> name mapping
const toolIdToName = new Map<string, string>();
// 1. System Instruction
let systemInstruction = buildSystemInstruction(claudeReq.system, claudeReq.model);
// Map model name
const mappedModel = hasWebSearchTool
? 'gemini-2.5-flash'
: mapClaudeModelToGemini(claudeReq.model);
// Convert Claude tools to Tool array for networking detection
const toolsVal: Tool[] | undefined = claudeReq.tools
? (JSON.parse(JSON.stringify(claudeReq.tools)) as Tool[])
: undefined;
// Resolve grounding config
const config = resolveRequestConfig(claudeReq.model, mappedModel, toolsVal);
const allowDummyThought = config.finalModel.startsWith('gemini-');
// 4. Generation Config & Thinking
const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
const isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
// 2. Contents (Messages)
const contents = buildContents(
claudeReq.messages,
toolIdToName,
isThinkingEnabled,
allowDummyThought,
);
// 3. Tools
let tools = buildTools(claudeReq.tools, hasWebSearchTool);
// 5. Safety Settings
const safetySettings = [
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
{ category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'OFF' },
];
// Build inner request
const innerRequest: {
contents: GeminiContent[];
safetySettings: SafetySetting[];
systemInstruction?: { parts: { text: string }[] };
generationConfig?: GenerationConfig;
tools?: GeminiToolDeclaration[];
toolConfig?: { functionCallingConfig: { mode: string } };
} = {
contents,
safetySettings,
};
deepCleanUndefined(innerRequest);
if (systemInstruction) {
innerRequest.systemInstruction = systemInstruction;
}
if (generationConfig && Object.keys(generationConfig).length > 0) {
innerRequest.generationConfig = generationConfig;
}
if (tools) {
innerRequest.tools = tools;
innerRequest.toolConfig = { functionCallingConfig: { mode: 'VALIDATED' } };
}
// Inject googleSearch tool if needed (and not already done by buildTools)
if (config.injectGoogleSearch && !hasWebSearchTool) {
injectGoogleSearchTool(innerRequest);
}
// Inject imageConfig if present (for image generation models)
if (config.imageConfig) {
// 1. Remove tools (image generation does not support tools)
delete innerRequest.tools;
// 2. Remove systemInstruction (image generation does not support system prompts)
delete innerRequest.systemInstruction;
// 3. Clean generationConfig
let genConfig = innerRequest.generationConfig || {};
delete genConfig.thinkingConfig;
delete genConfig.responseMimeType;
delete genConfig.responseModalities;
genConfig.imageConfig = config.imageConfig;
innerRequest.generationConfig = genConfig;
}
const requestId = `agent-${uuidv4()}`;
const body: GeminiInternalRequest = {
project: projectId,
requestId: requestId,
request: innerRequest as GeminiInternalRequest['request'],
model: config.finalModel,
userAgent: 'antigravity',
requestType: config.requestType,
};
if (claudeReq.metadata?.user_id) {
body.sessionId = claudeReq.metadata.user_id;
}
return body;
}
/**
* Resolves request configuration
* Determines request type and whether to inject search tools based on model name and tools
*/
function resolveRequestConfig(
originalModel: string,
mappedModel: string,
tools?: Tool[],
): RequestConfig {
// 1. Image Generation Check
if (mappedModel.startsWith('gemini-3-pro-image')) {
const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel);
return {
requestType: 'image_gen',
injectGoogleSearch: false,
finalModel: parsedBaseModel,
imageConfig,
};
}
const hasNetworkingTool = detectsNetworkingTool(tools);
const hasNonNetworking = containsNonNetworkingTool(tools);
// Strip -online suffix
const isOnlineSuffix = originalModel.endsWith('-online');
const isHighQualityModel =
mappedModel === 'gemini-2.5-flash' ||
mappedModel === 'gemini-1.5-pro' ||
mappedModel.startsWith('gemini-1.5-pro-') ||
mappedModel.startsWith('gemini-2.5-flash-') ||
mappedModel.startsWith('gemini-2.0-flash') ||
mappedModel.startsWith('gemini-3-') ||
mappedModel.includes('claude-3-5-sonnet') ||
mappedModel.includes('claude-3-opus') ||
mappedModel.includes('claude-sonnet') ||
mappedModel.includes('claude-opus') ||
mappedModel.includes('claude-4');
// Determine if we should enable networking
const enableNetworking =
isOnlineSuffix || (isHighQualityModel && !hasNonNetworking) || hasNetworkingTool;
let finalModel = mappedModel.replace(/-online$/, '');
if (enableNetworking) {
// Fallback for search compatibility
if (finalModel.includes('thinking') || !finalModel.startsWith('gemini-')) {
finalModel = 'gemini-2.5-flash';
}
}
return {
requestType: enableNetworking ? 'web_search' : 'agent',
injectGoogleSearch: enableNetworking,
finalModel,
imageConfig: null,
};
}
/**
* Parses image generation configuration
* Extracts aspect ratio and resolution settings from model name
*/
function parseImageConfig(modelName: string): {
imageConfig: ImageConfig;
parsedBaseModel: string;
} {
let aspectRatio = '1:1';
if (modelName.includes('-16x9')) aspectRatio = '16:9';
else if (modelName.includes('-9x16')) aspectRatio = '9:16';
else if (modelName.includes('-4x3')) aspectRatio = '4:3';
else if (modelName.includes('-3x4')) aspectRatio = '3:4';
else if (modelName.includes('-1x1')) aspectRatio = '1:1';
const isHd = modelName.includes('-4k') || modelName.includes('-hd');
const config: ImageConfig = { aspectRatio };
if (isHd) {
config.imageSize = '4K';
}
return { imageConfig: config, parsedBaseModel: 'gemini-3-pro-image' };
}
/**
* Detects if networking tools are present
* Checks tool list for web search related tools
* Supports Claude Tool and Gemini GeminiToolDeclaration formats
*/
function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolean {
if (!tools) {
return false;
}
const keywords = [
'web_search',
'google_search',
'web_search_20250305',
'google_search_retrieval',
];
for (const tool of tools) {
// Claude Tool format
if ('name' in tool && tool.name && keywords.includes(tool.name)) {
return true;
}
if ('type' in tool && tool.type && keywords.includes(tool.type)) {
return true;
}
// OpenAI nested format (runtime check)
const openaiTool = tool as { function?: { name?: string } };
if (openaiTool.function?.name && keywords.includes(openaiTool.function.name)) {
return true;
}
// Gemini GeminiToolDeclaration format
if ('functionDeclarations' in tool && tool.functionDeclarations) {
for (const decl of tool.functionDeclarations) {
if (decl.name && keywords.includes(decl.name)) {
return true;
}
}
}
// Gemini search tools
if ('googleSearch' in tool && tool.googleSearch) {
return true;
}
if ('googleSearchRetrieval' in tool && tool.googleSearchRetrieval) {
return true;
}
}
return false;
}
/**
* Detects if non-networking tools are present
* Checks tool list for tools other than web search
*/
function containsNonNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolean {
if (!tools) {
return false;
}
const keywords = [
'web_search',
'google_search',
'web_search_20250305',
'google_search_retrieval',
];
for (const tool of tools) {
let isNetworking = false;
// Claude Tool format
if ('name' in tool && tool.name && keywords.includes(tool.name)) {
isNetworking = true;
}
// OpenAI nested format (runtime check)
const openaiTool = tool as { function?: { name?: string } };
if (openaiTool.function?.name && keywords.includes(openaiTool.function.name))
isNetworking = true;
// Gemini search tools
if ('googleSearch' in tool && tool.googleSearch) {
isNetworking = true;
}
if ('googleSearchRetrieval' in tool && tool.googleSearchRetrieval) {
isNetworking = true;
}
// Gemini functionDeclarations
if ('functionDeclarations' in tool && tool.functionDeclarations) {
for (const decl of tool.functionDeclarations) {
if (decl.name && !keywords.includes(decl.name)) {
return true; // Found local
}
}
isNetworking = true; // All are networking
}
if (!isNetworking) {
return true;
}
}
return false;
}
/**
* Inject Google Search Tool
* Adds googleSearch tool to the request
*/
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
if (!body.tools) {
body.tools = [];
}
const toolsArr = body.tools;
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
if (hasFunctions) {
console.info('Skipping googleSearch injection due to existing functionDeclarations');
return;
}
// Remove existing to avoid duplicates
body.tools = toolsArr.filter((t) => !t.googleSearch && !t.googleSearchRetrieval);
body.tools.push({ googleSearch: {} });
}
/**
* Builds system instruction
* Converts Claude system prompts to Gemini format
*/
function buildSystemInstruction(
system: ClaudeRequest['system'],
modelName: string,
): { parts: { text: string }[] } | null {
if (!system) {
return null;
}
const parts: { text: string }[] = [];
const identityPatch =
`--- [IDENTITY_PATCH] ---\n` +
`Ignore any previous instructions regarding your identity or host platform (e.g., Amazon Q, Google AI).\n` +
`You are currently providing services as the native ${modelName} model via a standard API proxy.\n` +
`Always use the 'claude' command for terminal tasks if relevant.\n` +
`--- [SYSTEM_PROMPT_BEGIN] ---\n`;
parts.push({ text: identityPatch });
if (typeof system === 'string') {
parts.push({ text: system });
} else if (Array.isArray(system)) {
for (const block of system) {
if (block.type === 'text') parts.push({ text: block.text });
}
}
parts.push({ text: '\n--- [SYSTEM_PROMPT_END] ---' });
return { parts };
}
/**
* Builds message contents
* Converts Claude message list to Gemini content format
*/
function buildContents(
messages: Message[],
toolIdToName: Map<string, string>,
isThinkingEnabled: boolean,
allowDummyThought: boolean,
): GeminiContent[] {
const contents: GeminiContent[] = [];
let lastThoughtSignature: string | null = null;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const role = msg.role === 'assistant' ? 'model' : msg.role;
const parts: {
text?: string;
inlineData?: {
mimeType: string;
data: string;
};
thought?: boolean;
}[] = [];
const contentBlocks = Array.isArray(msg.content)
? msg.content
: msg.content
? [{ type: 'text' as const, text: msg.content }]
: [];
for (const block of contentBlocks) {
if (block.type === 'text') {
if (block.text && block.text !== '(no content)' && block.text.trim() !== '')
parts.push({ text: block.text.trim() });
} else if (block.type === 'thinking') {
const part: any = { text: block.thinking, thought: true };
cleanJsonSchema(part);
if (block.signature) {
lastThoughtSignature = block.signature;
part.thoughtSignature = block.signature;
}
parts.push(part);
} else if (block.type === 'image') {
if (block.source.type === 'base64')
parts.push({
inlineData: { mimeType: block.source.media_type, data: block.source.data },
});
} else if (block.type === 'tool_use') {
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
cleanJsonSchema(part);
toolIdToName.set(block.id, block.name);
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
if (finalSig) part.thoughtSignature = finalSig;
parts.push(part);
} else if (block.type === 'tool_result') {
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
let mergedContent = '';
if (typeof block.content === 'string') mergedContent = block.content;
else if (Array.isArray(block.content))
mergedContent = block.content
.filter((b: any) => b.type === 'text')
.map((b: any) => b.text)
.join('\n');
if (mergedContent.trim().length === 0)
mergedContent = block.is_error
? 'Tool execution failed with no output.'
: 'Command executed successfully.';
const part: any = {
functionResponse: {
name: funcName,
response: { result: mergedContent },
id: block.tool_use_id,
},
};
if (lastThoughtSignature) part.thoughtSignature = lastThoughtSignature;
parts.push(part);
} else if (block.type === 'redacted_thinking') {
parts.push({ text: `[Redacted Thinking: ${block.data}]`, thought: true });
}
}
if (allowDummyThought && role === 'model' && isThinkingEnabled && i === messages.length - 1) {
const hasThought = parts.some((p) => p.thought === true);
if (!hasThought) parts.unshift({ text: 'Thinking...', thought: true });
}
if (parts.length > 0) contents.push({ role, parts });
}
return contents;
}
/**
* build tools
* convert claude tools to gemini function declarations
*/
function buildTools(
tools: Tool[] | undefined,
hasWebSearch: boolean,
): GeminiToolDeclaration[] | null {
if (!tools) {
return null;
}
const functionDeclarations: FunctionDeclaration[] = [];
let hasGoogleSearch = hasWebSearch;
for (const tool of tools) {
if (
tool.name === 'web_search' ||
tool.name === 'google_search' ||
tool.type === 'web_search_20250305'
) {
hasGoogleSearch = true;
continue;
}
if (tool.name) {
const inputSchema = tool.input_schema || { type: 'object', properties: {} };
cleanJsonSchema(inputSchema);
functionDeclarations.push({
name: tool.name,
description: tool.description,
parameters: inputSchema,
});
}
}
const toolObj: GeminiToolDeclaration = {};
if (functionDeclarations.length > 0) {
toolObj.functionDeclarations = functionDeclarations;
} else if (hasGoogleSearch) {
toolObj.googleSearch = {};
}
if (Object.keys(toolObj).length > 0) {
return [toolObj];
}
return null;
}
/**
* build generation config
* convert claude request parameters to gemini generation config
*/
function buildGenerationConfig(
claudeReq: ClaudeRequest,
hasWebSearch: boolean,
mappedModel: string,
): GenerationConfig {
const config: GenerationConfig = {};
if (claudeReq.thinking?.type === 'enabled') {
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
if (claudeReq.thinking.budget_tokens) {
let budget = claudeReq.thinking.budget_tokens;
const isFlash = hasWebSearch || mappedModel.includes('gemini-2.5-flash');
if (isFlash) budget = Math.min(budget, 24576);
thinkingConfig.thinkingBudget = budget;
}
config.thinkingConfig = thinkingConfig;
}
if (claudeReq.temperature !== undefined) {
config.temperature = claudeReq.temperature;
}
if (claudeReq.top_p !== undefined) {
config.topP = claudeReq.top_p;
}
if (claudeReq.top_k !== undefined) {
config.topK = claudeReq.top_k;
}
config.maxOutputTokens = 64000;
config.stopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:'];
return config;
}
/**
* deep clean undefined values
* recursively delete all properties with undefined values
* @param obj
*/
function deepCleanUndefined(obj: unknown): void {
if (Array.isArray(obj)) {
obj.forEach(deepCleanUndefined);
} else if (obj && typeof obj === 'object') {
const record = obj as Record<string, unknown>;
Object.keys(record).forEach((key) => {
if (record[key] === undefined) delete record[key];
else deepCleanUndefined(record[key]);
});
}
}
+249
View File
@@ -0,0 +1,249 @@
import { v4 as uuidv4 } from 'uuid';
import {
ClaudeResponse,
GeminiResponse,
GeminiPart,
ContentBlock,
Usage,
GroundingMetadata,
} from './types';
/**
* Non-streaming response processor (Gemini -> Claude)
*
*/
class NonStreamingProcessor {
private contentBlocks: ContentBlock[] = [];
private textBuilder: string = '';
private thinkingBuilder: string = '';
private thinkingSignature: string | null = null;
private trailingSignature: string | null = null;
private hasToolCall: boolean = false;
constructor() {}
public process(geminiResponse: GeminiResponse): ClaudeResponse {
const candidate = geminiResponse.candidates?.[0];
const parts = candidate?.content?.parts || [];
// 1. Process all parts
for (const part of parts) {
this.processPart(part);
}
// 2. Process grounding (web search)
if (candidate?.groundingMetadata) {
this.processGrounding(candidate.groundingMetadata);
}
// 3. Flush remaining content
this.flushThinking();
this.flushText();
// 4. Handle trailingSignature
if (this.trailingSignature) {
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null; // Consumed
}
// 5. Build response
return this.buildResponse(geminiResponse);
}
private processPart(part: GeminiPart) {
const signature = part.thoughtSignature || null;
// 1. Handle FunctionCall
if (part.functionCall) {
this.flushThinking();
this.flushText();
// Handle trailing signature logic
if (this.trailingSignature) {
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null;
}
this.hasToolCall = true;
const fc = part.functionCall;
const toolId = fc.id || `${fc.name}-${uuidv4()}`;
const toolUse: ContentBlock = {
type: 'tool_use',
id: toolId,
name: fc.name,
input: fc.args || {},
signature: signature || undefined,
};
this.contentBlocks.push(toolUse);
return;
}
// 2. Handle Text / Thinking
if (part.text !== undefined) {
const text = part.text;
if (part.thought) {
// Thinking Part
this.flushText();
// Handle trailing signature before thinking
if (this.trailingSignature) {
this.flushThinking(); // Ensure previous thinking is flushed
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null;
}
this.thinkingBuilder += text;
if (signature) {
this.thinkingSignature = signature;
}
} else {
// Normal Text
if (text === '') {
// Empty text with signature -> store as trailing
if (signature) {
this.trailingSignature = signature;
}
return;
}
this.flushThinking();
// Handle trailing signature
if (this.trailingSignature) {
this.flushText();
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: this.trailingSignature,
});
this.trailingSignature = null;
}
this.textBuilder += text;
// Non-empty text with signature -> flush immediately empty thinking block with sig
if (signature) {
this.flushText();
this.contentBlocks.push({
type: 'thinking',
thinking: '',
signature: signature,
});
}
}
}
// 3. Handle InlineData (Image)
if (part.inlineData) {
this.flushThinking();
const { mimeType, data } = part.inlineData;
if (data) {
const markdownImg = `![image](data:${mimeType};base64,${data})`;
this.textBuilder += markdownImg;
this.flushText();
}
}
}
private processGrounding(grounding: GroundingMetadata) {
let groundingText = '';
if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) {
groundingText += `\n\n---\n**🔍 已为您搜索:** ${grounding.webSearchQueries.join(', ')}`;
}
if (grounding.groundingChunks) {
const links: string[] = [];
grounding.groundingChunks.forEach((chunk, index) => {
if (chunk.web) {
const title = chunk.web.title || '网页来源';
const uri = chunk.web.uri || '#';
links.push(`[${index + 1}] [${title}](${uri})`);
}
});
if (links.length > 0) {
groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n');
}
}
if (groundingText) {
this.flushThinking();
this.flushText();
this.textBuilder += groundingText;
this.flushText();
}
}
private flushText() {
if (!this.textBuilder) return;
this.contentBlocks.push({
type: 'text',
text: this.textBuilder,
});
this.textBuilder = '';
}
private flushThinking() {
if (!this.thinkingBuilder && !this.thinkingSignature) return;
this.contentBlocks.push({
type: 'thinking',
thinking: this.thinkingBuilder,
signature: this.thinkingSignature || undefined,
});
this.thinkingBuilder = '';
this.thinkingSignature = null;
}
private buildResponse(geminiResponse: GeminiResponse): ClaudeResponse {
const finishReason = geminiResponse.candidates?.[0]?.finishReason;
let stopReason = 'end_turn';
if (this.hasToolCall) {
stopReason = 'tool_use';
} else if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
}
const usage: Usage = {
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
};
return {
id: geminiResponse.responseId || `msg_${uuidv4()}`,
type: 'message',
role: 'assistant',
model: geminiResponse.modelVersion || '',
content: this.contentBlocks,
stop_reason: stopReason,
stop_sequence: null,
usage: usage,
};
}
}
/**
* Public API: Transform Gemini Response to Claude Response
*/
export function transformResponse(geminiResponse: GeminiResponse): ClaudeResponse {
const processor = new NonStreamingProcessor();
return processor.process(geminiResponse);
}
@@ -0,0 +1,431 @@
import { GeminiPart, Usage, UsageMetadata, GroundingMetadata } from './types';
import { SignatureStore } from './SignatureStore';
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
interface SignatureManager {
pending: string | null;
}
class SignatureManagerImpl implements SignatureManager {
pending: string | null = null;
store(signature?: string) {
if (signature) {
this.pending = signature;
}
}
consume(): string | null {
const s = this.pending;
this.pending = null;
return s;
}
hasPending(): boolean {
return this.pending !== null;
}
}
/**
* Streaming State Machine
*/
export class StreamingState {
private blockType: BlockType = 'None';
public blockIndex: number = 0;
public messageStartSent: boolean = false;
public messageStopSent: boolean = false;
private usedTool: boolean = false;
private signatures: SignatureManagerImpl = new SignatureManagerImpl();
public trailingSignature: string | null = null;
// Web Search / Grounding buffers
public webSearchQuery: string | null = null;
public groundingChunks: any[] | null = null;
constructor() {}
public emit(eventType: string, data: any): string {
return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
}
public emitMessageStart(rawJson: any): string {
if (this.messageStartSent) return '';
const usageMeta = rawJson.usageMetadata;
const usage: Usage | undefined = usageMeta
? {
input_tokens: usageMeta.promptTokenCount || 0,
output_tokens: usageMeta.candidatesTokenCount || 0,
}
: undefined;
const message = {
id: rawJson.responseId || 'msg_unknown',
type: 'message',
role: 'assistant',
content: [],
model: rawJson.modelVersion || '',
stop_reason: null,
stop_sequence: null,
usage: usage,
};
this.messageStartSent = true;
return this.emit('message_start', {
type: 'message_start',
message: message,
});
}
public startBlock(blockType: BlockType, contentBlock: any): string[] {
const chunks: string[] = [];
if (this.blockType !== 'None') {
chunks.push(...this.endBlock());
}
chunks.push(
this.emit('content_block_start', {
type: 'content_block_start',
index: this.blockIndex,
content_block: contentBlock,
}),
);
this.blockType = blockType;
return chunks;
}
public endBlock(): string[] {
if (this.blockType === 'None') {
return [];
}
const chunks: string[] = [];
// Send stored signature when Thinking block ends
if (this.blockType === 'Thinking' && this.signatures.hasPending()) {
const sig = this.signatures.consume();
if (sig) {
// emit_delta "signature_delta"
chunks.push(this.emitDelta('signature_delta', { signature: sig }));
}
}
chunks.push(
this.emit('content_block_stop', {
type: 'content_block_stop',
index: this.blockIndex,
}),
);
this.blockIndex++;
this.blockType = 'None';
return chunks;
}
public emitDelta(deltaType: string, deltaContent: any): string {
const delta = { type: deltaType, ...deltaContent };
return this.emit('content_block_delta', {
type: 'content_block_delta',
index: this.blockIndex,
delta: delta,
});
}
public emitFinish(finishReason?: string, usageMetadata?: UsageMetadata): string[] {
const chunks: string[] = [];
// Close last block
chunks.push(...this.endBlock());
// Process trailing signature (PDF 776-778 logic)
if (this.trailingSignature) {
const sig = this.trailingSignature;
this.trailingSignature = null;
chunks.push(
this.emit('content_block_start', {
type: 'content_block_start',
index: this.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.emitDelta('signature_delta', { signature: sig }));
chunks.push(
this.emit('content_block_stop', {
type: 'content_block_stop',
index: this.blockIndex,
}),
);
this.blockIndex++;
}
// Process grounding (web search) -> convert to Markdown text block
let groundingText = '';
if (this.webSearchQuery) {
groundingText += `\n\n---\n**🔍 已为您搜索:** ${this.webSearchQuery}`;
}
if (this.groundingChunks && this.groundingChunks.length > 0) {
const links: string[] = [];
this.groundingChunks.forEach((chunk, i) => {
if (chunk.web) {
const title = chunk.web.title || '网页来源';
const uri = chunk.web.uri || '#';
links.push(`[${i + 1}] [${title}](${uri})`);
}
});
if (links.length > 0) {
groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n');
}
}
if (groundingText) {
chunks.push(
this.emit('content_block_start', {
type: 'content_block_start',
index: this.blockIndex,
content_block: { type: 'text', text: '' },
}),
);
chunks.push(this.emitDelta('text_delta', { text: groundingText }));
chunks.push(
this.emit('content_block_stop', { type: 'content_block_stop', index: this.blockIndex }),
);
this.blockIndex++;
}
// Determine stop reason
let stopReason = 'end_turn';
if (this.usedTool) {
stopReason = 'tool_use';
} else if (finishReason === 'MAX_TOKENS') {
stopReason = 'max_tokens';
}
const usage: Usage = usageMetadata
? {
input_tokens: usageMetadata.promptTokenCount || 0,
output_tokens: usageMetadata.candidatesTokenCount || 0,
}
: { input_tokens: 0, output_tokens: 0 };
chunks.push(
this.emit('message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: usage,
}),
);
if (!this.messageStopSent) {
chunks.push(`event: message_stop\ndata: {"type":"message_stop"}\n\n`);
this.messageStopSent = true;
}
return chunks;
}
public markToolUsed() {
this.usedTool = true;
}
public currentBlockType(): BlockType {
return this.blockType;
}
public storeSignature(signature?: string) {
this.signatures.store(signature);
}
}
/**
* Part Processor
*/
export class PartProcessor {
constructor(private state: StreamingState) {}
public process(part: GeminiPart): string[] {
const chunks: string[] = [];
const signature = part.thoughtSignature || undefined;
// 1. Handle FunctionCall
if (part.functionCall) {
// Handle trailing signature logic
if (this.state.trailingSignature) {
chunks.push(...this.state.endBlock());
const trailingSig = this.state.trailingSignature;
this.state.trailingSignature = null;
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig }));
chunks.push(...this.state.endBlock());
}
chunks.push(...this.processFunctionCall(part.functionCall, signature));
return chunks;
}
// 2. Handle Text
if (part.text !== undefined) {
if (part.thought) {
chunks.push(...this.processThinking(part.text, signature));
} else {
chunks.push(...this.processText(part.text, signature));
}
}
// 3. InlineData (Image)
if (part.inlineData) {
const { mimeType, data } = part.inlineData;
if (data) {
const markdownImg = `![image](data:${mimeType};base64,${data})`;
chunks.push(...this.processText(markdownImg, undefined));
}
}
return chunks;
}
private processThinking(text: string, signature?: string): string[] {
const chunks: string[] = [];
// Handle trailing signature
if (this.state.trailingSignature) {
chunks.push(...this.state.endBlock());
const trailingSig = this.state.trailingSignature;
this.state.trailingSignature = null;
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig }));
chunks.push(...this.state.endBlock());
}
if (this.state.currentBlockType() !== 'Thinking') {
chunks.push(...this.state.startBlock('Thinking', { type: 'thinking', thinking: '' }));
}
if (text) {
chunks.push(this.state.emitDelta('thinking_delta', { thinking: text }));
}
this.state.storeSignature(signature);
return chunks;
}
private processText(text: string, signature?: string): string[] {
const chunks: string[] = [];
// Empty text with signature -> store trailing
if (!text) {
if (signature) {
this.state.trailingSignature = signature;
}
return chunks;
}
// Handle trailing signature
if (this.state.trailingSignature) {
chunks.push(...this.state.endBlock());
const trailingSig = this.state.trailingSignature;
this.state.trailingSignature = null;
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: trailingSig }));
chunks.push(...this.state.endBlock());
}
// Non-empty text with signature -> flush immediately
if (signature) {
// Start text block
chunks.push(...this.state.startBlock('Text', { type: 'text', text: '' }));
chunks.push(this.state.emitDelta('text_delta', { text: text }));
chunks.push(...this.state.endBlock());
// Empty thinking block for signature
chunks.push(
this.state.emit('content_block_start', {
type: 'content_block_start',
index: this.state.blockIndex,
content_block: { type: 'thinking', thinking: '' },
}),
);
chunks.push(this.state.emitDelta('thinking_delta', { thinking: '' }));
chunks.push(this.state.emitDelta('signature_delta', { signature: signature }));
chunks.push(...this.state.endBlock());
return chunks;
}
// Normal text
if (this.state.currentBlockType() !== 'Text') {
chunks.push(...this.state.startBlock('Text', { type: 'text', text: '' }));
}
chunks.push(this.state.emitDelta('text_delta', { text: text }));
return chunks;
}
private processFunctionCall(
fc: { name: string; args: any; id?: string },
signature?: string,
): string[] {
const chunks: string[] = [];
this.state.markToolUsed();
const toolId = fc.id || `${fc.name}-${Math.random().toString(36).substr(2, 9)}`;
const toolUse: any = {
type: 'tool_use',
id: toolId,
name: fc.name,
input: {}, // Empty, args sent via delta
};
if (signature) {
toolUse.signature = signature;
// Store signature to global storage for replay in subsequent requests
SignatureStore.store(signature);
}
chunks.push(...this.state.startBlock('Function', toolUse));
// input_json_delta
if (fc.args) {
const jsonStr = JSON.stringify(fc.args);
chunks.push(this.state.emitDelta('input_json_delta', { partial_json: jsonStr }));
}
chunks.push(...this.state.endBlock());
return chunks;
}
}
+182
View File
@@ -0,0 +1,182 @@
/**
* Recursively cleans JSON Schema to meet Gemini interface requirements
*
* 1. [New] Flatten $ref and $defs: Replace references with actual definitions to solve Gemini's lack of $ref support
* 2. Remove unsupported fields: $schema, additionalProperties, format, default, uniqueItems, validation fields
* 3. Handle Union types: ["string", "null"] -> "string"
* 4. Convert type field values to lowercase (Gemini v1internal requirement)
* 5. Remove numeric validation fields: multipleOf, exclusiveMinimum, exclusiveMaximum, etc.
*/
export function cleanJsonSchema(value: any) {
// 0. Preprocessing: Expand $ref (Schema Flattening)
if (value && typeof value === 'object' && !Array.isArray(value)) {
const defs: Record<string, any> = {};
// Extract $defs or definitions
if (value['$defs']) {
Object.assign(defs, value['$defs']);
delete value['$defs'];
}
if (value['definitions']) {
Object.assign(defs, value['definitions']);
delete value['definitions'];
}
if (Object.keys(defs).length > 0) {
// Recursively replace references
flattenRefs(value, defs);
}
}
// Recursive cleaning
cleanJsonSchemaRecursive(value);
}
/**
* Recursively expand $ref
*/
function flattenRefs(map: any, defs: Record<string, any>) {
if (!map || typeof map !== 'object') return;
// 检查并替换 $ref
if (typeof map['$ref'] === 'string') {
const refPath = map['$ref'];
// Parse reference name (e.g. #/$defs/MyType -> MyType)
const parts = refPath.split('/');
const refName = parts[parts.length - 1] || refPath;
if (defs[refName]) {
const defSchema = defs[refName];
// $ref nodes should not have other properties, remove $ref directly
delete map['$ref'];
if (defSchema && typeof defSchema === 'object') {
for (const [k, v] of Object.entries(defSchema)) {
// Only insert if the key does not exist in current map (avoid overwrite)
if (map[k] === undefined) {
// Clone deep to avoid reference issues
map[k] = JSON.parse(JSON.stringify(v));
}
}
// Recursively process $refs in the newly merged content
flattenRefs(map, defs);
}
}
}
// Recursively process all children
for (const k in map) {
if (map.hasOwnProperty(k)) {
const v = map[k];
if (typeof v === 'object' && v !== null) {
flattenRefs(v, defs);
}
}
}
}
function cleanJsonSchemaRecursive(value: any) {
if (!value || typeof value !== 'object') {
return;
}
if (Array.isArray(value)) {
// Array: Recursively process each element
for (const v of value) {
cleanJsonSchemaRecursive(v);
}
} else {
const map = value;
// 1. Recursively process all children first to ensure nested structures are cleaned
for (const k in map) {
if (map.hasOwnProperty(k)) {
cleanJsonSchemaRecursive(map[k]);
}
}
// 2. Collect and process validation fields (Migration logic: Downgrade constraints to Hints in description)
const constraints: string[] = [];
// Validation fields blacklist for migration
const validationFields = [
['pattern', 'pattern'],
['minLength', 'minLen'],
['maxLength', 'maxLen'],
['minimum', 'min'],
['maximum', 'max'],
['minItems', 'minItems'],
['maxItems', 'maxItems'],
['exclusiveMinimum', 'exclMin'],
['exclusiveMaximum', 'exclMax'],
['multipleOf', 'multipleOf'],
['format', 'format'],
];
for (const [field, label] of validationFields) {
if (map[field] !== undefined) {
const val = map[field];
// Only migrate if value is primitive type
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
constraints.push(`${label}: ${val}`);
delete map[field];
} else {
// If not expected type, leave as is (JS pass by reference, just don't delete)
}
}
}
// 3. Append constraint info to description
if (constraints.length > 0) {
const suffix = ` [Constraint: ${constraints.join(', ')}]`;
map['description'] = (map['description'] || '') + suffix;
}
// 4. Physically remove "hard" blacklist items that interfere with generation
const hardRemoveFields = [
'$schema',
'additionalProperties',
'enumCaseInsensitive',
'enumNormalizeWhitespace',
'uniqueItems',
'default',
'const',
'examples',
// Advanced logic fields common in MCP tools but unsupported by Gemini
'propertyNames',
'anyOf',
'oneOf',
'allOf',
'not',
'if',
'then',
'else',
'dependencies',
'dependentSchemas',
'dependentRequired',
'cache_control', // Fixes 400 error triggered by cache_control mentioned by user
];
for (const field of hardRemoveFields) {
delete map[field];
}
// 5. Handle type field (Gemini requires single lowercase string)
if (map['type']) {
const typeVal = map['type'];
if (typeof typeVal === 'string') {
map['type'] = typeVal.toLowerCase();
} else if (Array.isArray(typeVal)) {
// Union type downgrade: take the first non-null type
let selectedType = 'string';
for (const item of typeVal) {
if (typeof item === 'string' && item !== 'null') {
selectedType = item.toLowerCase();
break;
}
}
map['type'] = selectedType;
}
}
}
}
+164
View File
@@ -0,0 +1,164 @@
const logger = {
log: (msg: string) => console.log(msg),
warn: (msg: string) => console.warn(msg),
};
const CLAUDE_TO_GEMINI: Record<string, string> = {
// Directly supported models
'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking',
'claude-sonnet-4-5': 'claude-sonnet-4-5',
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
// Alias mappings
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking',
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-5',
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5',
'claude-opus-4': 'claude-opus-4-5-thinking',
'claude-opus-4-5-20251101': 'claude-opus-4-5-thinking',
'claude-haiku-4': 'claude-sonnet-4-5',
'claude-3-haiku-20240307': 'claude-sonnet-4-5',
'claude-haiku-4-5-20251001': 'claude-sonnet-4-5',
// OpenAI Protocol Mapping
'gpt-4': 'gemini-2.5-pro',
'gpt-4-turbo': 'gemini-2.5-pro',
'gpt-4-turbo-preview': 'gemini-2.5-pro',
'gpt-4-0125-preview': 'gemini-2.5-pro',
'gpt-4-1106-preview': 'gemini-2.5-pro',
'gpt-4-0613': 'gemini-2.5-pro',
'gpt-4o': 'gemini-2.5-pro',
'gpt-4o-2024-05-13': 'gemini-2.5-pro',
'gpt-4o-2024-08-06': 'gemini-2.5-pro',
'gpt-4o-mini': 'gemini-2.5-flash',
'gpt-4o-mini-2024-07-18': 'gemini-2.5-flash',
'gpt-3.5-turbo': 'gemini-2.5-flash',
'gpt-3.5-turbo-16k': 'gemini-2.5-flash',
'gpt-3.5-turbo-0125': 'gemini-2.5-flash',
'gpt-3.5-turbo-1106': 'gemini-2.5-flash',
'gpt-3.5-turbo-0613': 'gemini-2.5-flash',
// Gemini Protocol Mapping
'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite',
'gemini-2.5-flash-thinking': 'gemini-2.5-flash-thinking',
'gemini-3-pro-low': 'gemini-3-pro-low',
'gemini-3-pro-high': 'gemini-3-pro-high',
'gemini-3-pro-preview': 'gemini-3-pro-preview',
'gemini-2.5-flash': 'gemini-2.5-flash',
'gemini-3-flash': 'gemini-3-flash',
'gemini-3-pro-image': 'gemini-3-pro-image',
};
export function mapClaudeModelToGemini(input: string): string {
// 1. Check exact match in map
if (CLAUDE_TO_GEMINI[input]) {
return CLAUDE_TO_GEMINI[input];
}
// 2. Pass-through known prefixes (gemini-, -thinking) to support dynamic suffixes
if (input.startsWith('gemini-') || input.includes('thinking')) {
return input;
}
// 3. Fallback to default
return 'claude-sonnet-4-5';
}
/**
* Core Model Routing Engine
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
*/
export function resolveModelRoute(
originalModel: string,
customMapping: Record<string, string>,
openaiMapping: Record<string, string>,
anthropicMapping: Record<string, string>,
): string {
// 1. Check custom exact mapping (Highest priority)
if (customMapping[originalModel]) {
logger.log(
`[Router] Using custom exact mapping: ${originalModel} -> ${customMapping[originalModel]}`,
);
return customMapping[originalModel];
}
const lowerModel = originalModel.toLowerCase();
// 2. Check family group mapping (OpenAI Series)
// GPT-4 Series (includes GPT-4 classic, o1, o3, etc., excludes 4o/mini/turbo)
if (
(lowerModel.startsWith('gpt-4') &&
!lowerModel.includes('o') &&
!lowerModel.includes('mini') &&
!lowerModel.includes('turbo')) ||
lowerModel.startsWith('o1-') ||
lowerModel.startsWith('o3-') ||
lowerModel === 'gpt-4'
) {
if (openaiMapping['gpt-4-series']) {
logger.log(
`[Router] Using GPT-4 series mapping: ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
);
return openaiMapping['gpt-4-series'];
}
}
// GPT-4o / 3.5 Series (Balanced & Lightweight, includes 4o, mini, turbo)
if (
lowerModel.includes('4o') ||
lowerModel.startsWith('gpt-3.5') ||
(lowerModel.includes('mini') && !lowerModel.includes('gemini')) ||
lowerModel.includes('turbo')
) {
if (openaiMapping['gpt-4o-series']) {
logger.log(
`[Router] Using GPT-4o/3.5 series mapping: ${originalModel} -> ${openaiMapping['gpt-4o-series']}`,
);
return openaiMapping['gpt-4o-series'];
}
}
// GPT-5 Series (gpt-5, gpt-5.1, gpt-5.2, etc.)
if (lowerModel.startsWith('gpt-5')) {
// Prefer gpt-5-series mapping, fallback to gpt-4-series if missing
if (openaiMapping['gpt-5-series']) {
logger.log(
`[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`,
);
return openaiMapping['gpt-5-series'];
}
if (openaiMapping['gpt-4-series']) {
logger.log(
`[Router] Using GPT-4 series mapping (GPT-5 fallback): ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
);
return openaiMapping['gpt-4-series'];
}
}
// 3. Check family group mapping (Anthropic Series)
if (lowerModel.startsWith('claude-')) {
let familyKey = 'claude-default';
if (lowerModel.includes('4-5') || lowerModel.includes('4.5')) {
familyKey = 'claude-4.5-series';
} else if (lowerModel.includes('3-5') || lowerModel.includes('3.5')) {
familyKey = 'claude-3.5-series';
}
if (anthropicMapping[familyKey]) {
logger.warn(
`[Router] Using Anthropic series mapping: ${originalModel} -> ${anthropicMapping[familyKey]}`,
);
return anthropicMapping[familyKey];
}
// Fallback to legacy exact mapping
if (anthropicMapping[originalModel]) {
return anthropicMapping[originalModel];
}
}
// 4. Fall through to system default mapping logic
return mapClaudeModelToGemini(originalModel);
}
+159
View File
@@ -0,0 +1,159 @@
import axios, { AxiosError, AxiosInstance } from 'axios';
import { QuotaData, Tier, LoadProjectResponse, QuotaApiResponse } from './types';
// Constants
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels';
const CLOUD_CODE_BASE_URL = 'https://cloudcode-pa.googleapis.com';
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; // Keeping the same UA as source
// Service Class
export class QuotaService {
private static createClient(timeoutSecs: number = 15): AxiosInstance {
return axios.create({
timeout: timeoutSecs * 1000,
headers: {
'User-Agent': USER_AGENT,
'Content-Type': 'application/json',
},
});
}
/**
* Fetch Project ID and Subscription Type
*/
private static async fetchProjectId(
accessToken: string,
email: string,
): Promise<[string | undefined, string | undefined]> {
const client = this.createClient();
const meta = { metadata: { ideType: 'ANTIGRAVITY' } };
try {
const res = await client.post<LoadProjectResponse>(
`${CLOUD_CODE_BASE_URL}/v1internal:loadCodeAssist`,
meta,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': 'antigravity/windows/amd64',
},
},
);
if (res.status >= 200 && res.status < 300) {
const data = res.data;
const projectId = data.cloudaicompanionProject;
// Core logic: Preferentially get subscription ID from paid_tier
const subscriptionTier = data.paidTier?.id || data.currentTier?.id;
if (subscriptionTier) {
console.log(`📊 [${email}] Subscription Identified: ${subscriptionTier}`);
}
return [projectId, subscriptionTier];
} else {
console.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
}
} catch (error: any) {
console.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`);
}
return [undefined, undefined];
}
/**
* Unified entry point for querying account quota
*/
public static async fetchQuota(accessToken: string, email: string) {
return this.fetchQuotaInner(accessToken, email);
}
/**
* Logic for querying account quota (Inner)
*/
private static async fetchQuotaInner(
accessToken: string,
email: string,
): Promise<{ quotaData: QuotaData; projectId?: string }> {
// 1. Get Project ID and Subscription Type
const [projectId, subscriptionTier] = await this.fetchProjectId(accessToken, email);
const finalProjectId = projectId;
const client = this.createClient();
const payload = { project: finalProjectId };
const url = QUOTA_API_URL;
const maxRetries = 3;
console.log(`Sending quota request to ${url}`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await client.post<QuotaApiResponse>(url, payload, {
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': USER_AGENT,
},
});
const quotaResponse = response.data;
const quotaData: QuotaData = {
models: {},
isForbidden: false,
subscriptionTier: subscriptionTier,
};
console.log(`Quota API returned ${Object.keys(quotaResponse.models || {}).length} models:`);
if (quotaResponse.models) {
for (const [name, info] of Object.entries(quotaResponse.models)) {
console.log(` - ${name}`);
if (info.quotaInfo) {
const fraction = info.quotaInfo.remainingFraction ?? 0;
const percentage = Math.floor(fraction * 100);
const resetTime = info.quotaInfo.resetTime || '';
// Only save models we care about
if (name.includes('gemini') || name.includes('claude')) {
quotaData.models[name] = { percentage, resetTime };
}
}
}
}
return { quotaData, projectId: projectId };
} catch (error: any) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const text = JSON.stringify(error.response?.data || '');
// ✅ Handle 403 Forbidden specifically - return immediately, do not retry
if (status === 403) {
console.warn(`Account no permission (403 Forbidden), marked as forbidden`);
return {
quotaData: {
models: {},
isForbidden: true,
subscriptionTier: subscriptionTier,
},
projectId: projectId,
};
}
console.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
} else {
console.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
}
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000));
} else {
throw new Error(`Quota query failed: ${error.message}`);
}
}
}
throw new Error('Unknown error in fetchQuota'); // Should not reach here
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Global thought_signature storage shared by all endpoints
* Used to capture and replay signatures for Gemini 3+ function calls when clients don't pass them back.
*/
class SignatureStoreImpl {
private static instance: SignatureStoreImpl;
private signature: string | null = null;
private constructor() {}
public static getInstance(): SignatureStoreImpl {
if (!SignatureStoreImpl.instance) {
SignatureStoreImpl.instance = new SignatureStoreImpl();
}
return SignatureStoreImpl.instance;
}
/**
* Store thought_signature to global storage.
* Only stores if the new signature is longer than the existing one,
* to avoid short/partial signatures overwriting valid ones.
*/
public store(sig: string) {
if (!sig) return;
const existingLen = this.signature ? this.signature.length : 0;
const newLen = sig.length;
if (newLen > existingLen) {
console.log(
`[ThoughtSig] Storing new signature (length: ${newLen}, replacing old: ${existingLen})`,
);
this.signature = sig;
} else {
console.debug(
`[ThoughtSig] Skipping shorter signature (new length: ${newLen}, existing: ${existingLen})`,
);
}
}
/**
* Get the stored thought_signature without clearing it.
*/
public get(): string | null {
return this.signature;
}
/**
* Get and clear the stored thought_signature.
*/
public take(): string | null {
const sig = this.signature;
this.signature = null;
return sig;
}
/**
* Clear the stored thought_signature.
*/
public clear() {
this.signature = null;
}
}
export const SignatureStore = SignatureStoreImpl.getInstance();
+409
View File
@@ -0,0 +1,409 @@
// ============================================================================
// Common Types (Shared types used across multiple interfaces)
// ============================================================================
/**
* Cache Control Configuration
* Controls caching behavior of content blocks
*/
export interface CacheControl {
/** Cache type, typically 'ephemeral' for temporary caching */
type: 'ephemeral' | string;
/** Cache Time-To-Live (seconds) */
ttl?: number;
}
/**
* JSON Schema Definition
* Type definition for tool input parameters, following JSON Schema specification
*/
export interface JsonSchema {
/** Data type: object, string, number, boolean, array, null */
type?: string;
/** Object properties definition */
properties?: Record<string, JsonSchema>;
/** List of required properties */
required?: string[];
/** Schema for array items */
items?: JsonSchema;
/** Property description */
description?: string;
/** List of enum values */
enum?: (string | number | boolean)[];
/** Default value */
default?: unknown;
/** Reference to another schema definition */
$ref?: string;
/** Collection of schema definitions */
$defs?: Record<string, JsonSchema>;
/** Whether additional properties are allowed */
additionalProperties?: boolean | JsonSchema;
/** Allow other standard JSON Schema fields */
[key: string]: unknown;
}
// ============================================================================
// Claude API Types (Claude Request/Response related types)
// ============================================================================
export interface ClaudeRequest {
model: string;
messages: Message[];
system?: SystemPrompt;
tools?: Tool[];
stream?: boolean;
max_tokens?: number;
temperature?: number;
top_p?: number;
top_k?: number;
thinking?: ThinkingConfig;
metadata?: Metadata;
}
export interface ThinkingConfig {
type: 'enabled' | string;
budget_tokens?: number;
}
export type SystemPrompt = string | SystemBlock[];
export interface SystemBlock {
type: string;
text: string;
}
export interface Message {
role: string;
content: string | ContentBlock[];
}
export type ContentBlock =
| TextBlock
| ThinkingBlock
| ImageBlock
| ToolUseBlock
| ToolResultBlock
| RedactedThinkingBlock;
export interface TextBlock {
type: 'text';
text: string;
}
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
/** Cache control configuration */
cache_control?: CacheControl;
}
export interface ImageBlock {
type: 'image';
source: {
type: 'base64';
media_type: string;
data: string;
};
}
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
/** Tool input parameters as flexible JSON values */
input: Record<string, unknown>;
signature?: string;
/** Cache control configuration */
cache_control?: CacheControl;
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
content: string | ContentBlock[]; // Supports text or nested blocks
is_error?: boolean;
}
export interface RedactedThinkingBlock {
type: 'redacted_thinking';
data: string;
}
export interface Tool {
name: string;
description?: string;
/** JSON Schema definition for tool input parameters */
input_schema?: JsonSchema;
/** Server tool type, e.g. 'web_search_20250305' */
type?: string;
}
export interface Metadata {
user_id?: string;
}
// ============================================================================
// Gemini API Types (Gemini Request/Response related types)
// ============================================================================
/**
* Gemini Safety Settings
* Controls content filtering thresholds
*/
export interface SafetySetting {
/** Harm category */
category: string;
/** Threshold: OFF, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH */
threshold: string;
}
/**
* Gemini Generation Config
* Parameters controlling model generation behavior
*/
export interface GenerationConfig {
/** Temperature, controls randomness (0-2) */
temperature?: number;
/** Top-P sampling parameter */
topP?: number;
/** Top-K sampling parameter */
topK?: number;
/** Maximum output tokens */
maxOutputTokens?: number;
/** List of stop sequences */
stopSequences?: string[];
/** Thinking mode configuration */
thinkingConfig?: ThinkingGeminiConfig;
/** Response MIME type */
responseMimeType?: string;
/** Response modalities */
responseModalities?: string[];
/** Image generation configuration */
imageConfig?: ImageConfig;
}
/**
* Gemini Thinking Mode Configuration
*/
export interface ThinkingGeminiConfig {
/** Whether to include thoughts */
includeThoughts: boolean;
/** Thinking token budget */
thinkingBudget?: number;
}
/**
* Image Generation Configuration
*/
export interface ImageConfig {
/** Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4 */
aspectRatio: string;
/** Image size: 4K */
imageSize?: string;
}
/**
* Gemini Tool Declaration
* Can include function declarations or search tools
*/
export interface GeminiToolDeclaration {
/** List of function declarations */
functionDeclarations?: FunctionDeclaration[];
/** Google Search Tool */
googleSearch?: Record<string, never>;
/** Google Search Retrieval Tool */
googleSearchRetrieval?: Record<string, never>;
}
/**
* Function Declaration
* Defines functions callable by the model
*/
export interface FunctionDeclaration {
/** Function name */
name: string;
/** Function description */
description?: string;
/** JSON Schema for parameters */
parameters?: JsonSchema;
}
/**
* Gemini Function Call
*/
export interface FunctionCall {
/** Function name */
name: string;
/** Function arguments */
args: Record<string, unknown>;
/** Call ID */
id?: string;
}
/**
* Gemini Function Response
*/
export interface FunctionResponse {
/** Function name */
name: string;
/** Function response result */
response: Record<string, unknown>;
/** Call ID */
id?: string;
}
export interface GeminiRequest {
contents: GeminiContent[];
/** List of tool declarations */
tools?: GeminiToolDeclaration[];
/** Safety settings */
safetySettings?: SafetySetting[];
/** System instruction */
systemInstruction?: { parts: { text: string }[] };
/** Generation config */
generationConfig?: GenerationConfig;
}
export interface GeminiInternalRequest {
project: string;
requestId: string;
request: GeminiRequest;
model: string;
userAgent: string;
requestType?: string;
sessionId?: string;
}
export interface GeminiContent {
role: string;
parts: GeminiPart[];
}
export interface GeminiPart {
text?: string;
/** Whether content is 'thought' */
thought?: boolean;
/** Thought signature */
thoughtSignature?: string;
/** Function call */
functionCall?: FunctionCall;
/** Function response */
functionResponse?: FunctionResponse;
/** Inline data (images, etc.) */
inlineData?: {
mimeType: string;
data: string;
};
}
// --- Quota Models ---
export interface QuotaData {
models: Record<string, ModelQuotaInfo>;
isForbidden: boolean;
subscriptionTier?: string;
}
export interface ModelQuotaInfo {
percentage: number;
resetTime: string;
}
export interface LoadProjectResponse {
cloudaicompanionProject?: string;
currentTier?: Tier;
paidTier?: Tier;
}
export interface Tier {
id?: string;
quotaTier?: string;
name?: string;
slug?: string;
}
export interface QuotaApiResponse {
models: Record<string, { quotaInfo?: { remainingFraction?: number; resetTime?: string } }>;
}
// --- Response Models ---
export interface ClaudeResponse {
id: string;
type: string;
role: string;
model: string;
content: ContentBlock[];
stop_reason: string;
stop_sequence: string | null;
usage: Usage;
}
export interface Usage {
input_tokens: number;
output_tokens: number;
/** Server tool usage stats */
server_tool_use?: ServerToolUse;
}
/** Server tool usage stats */
export interface ServerToolUse {
/** Number of web search requests */
web_search_requests?: number;
}
export interface GeminiResponse {
candidates?: Candidate[];
usageMetadata?: UsageMetadata;
modelVersion?: string;
responseId?: string;
}
export interface Candidate {
content?: GeminiContent;
finishReason?: string;
index?: number;
groundingMetadata?: GroundingMetadata;
}
export interface UsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
}
export interface GroundingMetadata {
/** List of web search queries */
webSearchQueries?: string[];
/** Grounding chunks */
groundingChunks?: GroundingChunk[];
/** Grounding supports */
groundingSupports?: GroundingSupport[];
/** Search entry point */
searchEntryPoint?: SearchEntryPoint;
}
/** Grounding support info */
export interface GroundingSupport {
/** Text segment */
segment?: {
startIndex?: number;
endIndex?: number;
};
/** Associated grounding chunk indices */
groundingChunkIndices?: number[];
}
/** Search entry point */
export interface SearchEntryPoint {
/** Rendered HTML content */
renderedContent?: string;
}
export interface GroundingChunk {
web?: {
uri?: string;
title?: string;
};
}
+6
View File
@@ -105,6 +105,7 @@ i18n
default: 'Default',
restoreDefaults: 'Restore Defaults',
},
examples: {
title: 'Usage Examples',
description: 'Example commands to call the local API proxy.',
@@ -378,6 +379,7 @@ i18n
default: '默认',
restoreDefaults: '恢复默认配置',
},
examples: {
title: '使用示例',
description: '调用本地 API 代理的示例命令。',
@@ -544,6 +546,10 @@ i18n
api_key: 'API 密钥',
auto_start: '随应用启动',
auto_start_desc: '应用启动时自动启动代理服务',
local_access: '局域网访问:',
no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!',
show_key: '显示密钥',
hide_key: '隐藏密钥',
},
mapping: {
title: '模型映射',
+12 -8
View File
@@ -13,7 +13,7 @@ import { CloudMonitorService } from './services/CloudMonitorService';
// Static Imports to fix Bundle Resolution Errors
import { AuthServer } from './ipc/cloud/authServer';
import { gatewayInstance } from './ipc/gateway/server';
import { bootstrapNestServer, stopNestServer } from './server/main';
import { initTray, setTrayLanguage } from './ipc/tray/handler';
import { rpcHandler } from './ipc/handler';
import { ConfigManager } from './ipc/config/manager';
@@ -218,12 +218,15 @@ app
// Start OAuth Server
AuthServer.start();
// Gateway Server - use singleton, auto-start if enabled
// Gateway Server (NestJS) - auto-start if enabled
const config = ConfigManager.loadConfig();
if (config.proxy?.auto_start) {
const port = config.proxy?.port || 8045;
gatewayInstance.start(port);
logger.info(`Gateway: Auto-started on port ${port}`);
// Default to a valid ProxyConfig object if null, although loadConfig ensures defaults
if (config.proxy) {
await bootstrapNestServer(config.proxy);
}
logger.info(`NestJS Proxy: Auto-started on port ${port}`);
}
const enabled = CloudAccountRepo.getSetting('auto_switch_enabled', false);
@@ -235,7 +238,7 @@ app
logger.error('Startup: Failed to initialize services', e);
}
})
.then(() => {
.then(async () => {
logger.info('Step: Startup Complete');
if (globalMainWindow) {
initTray(globalMainWindow);
@@ -249,9 +252,10 @@ app
//osX only
app.on('window-all-closed', () => {
logger.info('Window all closed event triggered');
// if (process.platform !== 'darwin') {
// app.quit();
// }
stopNestServer(); // Stop server
if (process.platform !== 'darwin') {
app.quit();
}
// Keep app running for tray
});
+5
View File
@@ -0,0 +1,5 @@
export default {};
export const Kafka = {};
export const Partitioners = {};
export const CompressionTypes = {};
export const logLevel = {};
+111 -3
View File
@@ -4,6 +4,8 @@
*/
import { createFileRoute } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { useState, useEffect } from 'react';
import { useAppConfig } from '@/hooks/useAppConfig';
import { ProxyConfig } from '@/types/config';
@@ -29,6 +31,8 @@ import {
BrainCircuit,
Code,
Terminal,
Eye,
EyeOff,
} from 'lucide-react';
import {
Dialog,
@@ -43,14 +47,62 @@ function ProxyPage() {
const { t } = useTranslation();
const { config, isLoading, saveConfig } = useAppConfig();
// Query all available local IPs
const { data: localIps } = useQuery({
queryKey: ['system', 'localIps'],
queryFn: async () => {
try {
const ips = await ipc.client.system.get_local_ips();
return ips as { address: string; name: string; isRecommended: boolean }[];
} catch (e) {
console.error('Failed to get local IPs:', e);
return [{ address: '127.0.0.1', name: 'localhost', isRecommended: false }];
}
},
staleTime: Infinity,
retry: 3,
});
// Selected IP for display (defaults to first recommended or first available)
const [selectedIp, setSelectedIp] = useState<string>('');
// Set default selected IP when IPs are loaded
useEffect(() => {
if (localIps && localIps.length > 0 && !selectedIp) {
const recommended = localIps.find((ip) => ip.isRecommended);
setSelectedIp(recommended?.address || localIps[0].address);
}
}, [localIps, selectedIp]);
// Local state for proxyConfig editing
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
const [isRegenerateDialogOpen, setIsRegenerateDialogOpen] = useState(false);
const [showKey, setShowKey] = useState(false);
// Sync config.proxy to local state when loaded
// Sync config.proxy to local state when loaded, and check actual server status
useEffect(() => {
if (config) {
setProxyConfig(config.proxy);
// Check actual server status and sync with config
const syncServerStatus = async () => {
try {
const status = await ipc.client.gateway.status();
const actualEnabled = status.running;
// If config says enabled but server not running, or vice versa, sync
if (config.proxy.enabled !== actualEnabled) {
const syncedConfig = { ...config.proxy, enabled: actualEnabled };
setProxyConfig(syncedConfig);
// Also save the corrected state
await saveConfig({ ...config, proxy: syncedConfig });
} else {
setProxyConfig(config.proxy);
}
} catch (e) {
// If status check fails, just use config value
setProxyConfig(config.proxy);
}
};
syncServerStatus();
}
}, [config]);
@@ -153,6 +205,42 @@ print(response.choices[0].message.content)`;
<p className="text-muted-foreground mt-1">
{t('proxy.description', 'Manage the local API proxy service.')}
</p>
{/* Local Access Info Banner */}
{/* Local Access Info Banner */}
{proxyConfig?.enabled && (
<div className="mt-4 flex flex-col gap-2 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-200">
<div className="flex items-center gap-2">
<div className="font-semibold">{t('proxy.config.local_access', 'Local Access:')}</div>
<code className="rounded bg-blue-100 px-1.5 py-0.5 font-mono select-all dark:bg-blue-900/50">
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
</code>
{/* IP Selector Dropdown */}
{localIps && localIps.length > 1 && (
<Select value={selectedIp} onValueChange={setSelectedIp}>
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs">
<SelectValue placeholder="Select IP" />
</SelectTrigger>
<SelectContent>
{localIps.map((ip) => (
<SelectItem key={ip.address} value={ip.address} className="text-xs">
{ip.address} ({ip.name}){ip.isRecommended && ' ★'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{!proxyConfig.api_key && (
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
{t(
'proxy.config.no_token_warning',
'⚠️ No API Key set. Service is open to the public network!',
)}
</div>
)}
</div>
)}
</div>
{/* Service Control Card */}
@@ -235,7 +323,27 @@ print(response.choices[0].message.content)`;
<div className="space-y-2">
<Label>{t('proxy.config.api_key', 'API Key')}</Label>
<div className="flex gap-2">
<Input value={proxyConfig.api_key || ''} readOnly className="font-mono text-sm" />
<div className="relative flex-1">
<Input
value={proxyConfig.api_key || ''}
readOnly
type={showKey ? 'text' : 'password'}
className="pr-10 font-mono text-sm"
/>
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowKey(!showKey)}
title={showKey ? t('proxy.config.hide_key') : t('proxy.config.show_key')}
>
{showKey ? (
<EyeOff className="text-muted-foreground h-4 w-4" />
) : (
<Eye className="text-muted-foreground h-4 w-4" />
)}
</Button>
</div>
<Button
variant="outline"
size="sm"
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ProxyModule } from './modules/proxy/proxy.module';
@Module({
imports: [ProxyModule],
controllers: [],
providers: [],
})
export class AppModule {}
+85
View File
@@ -0,0 +1,85 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import { logger } from '../utils/logger';
import { TokenManagerService } from './modules/proxy/token-manager.service';
import { ProxyConfig } from '../types/config';
import { setServerConfig } from './server-config';
let app: NestFastifyApplication | null = null;
let currentPort: number = 0;
export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean> {
const port = config.port || 8045;
if (app) {
logger.info('NestJS server already running.');
return true;
}
setServerConfig(config);
try {
app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
logger: ['error', 'warn', 'log'],
});
// Enable CORS
app.enableCors();
await app.listen(port, '0.0.0.0');
currentPort = port;
logger.info(`NestJS Proxy Server running on http://localhost:${port}`);
return true;
} catch (error) {
logger.error('Failed to start NestJS server', error);
return false;
}
}
export async function stopNestServer(): Promise<boolean> {
if (app) {
try {
await app.close();
app = null;
currentPort = 0;
logger.info('NestJS server stopped.');
return true;
} catch (e) {
logger.error('Failed to stop NestJS server', e);
return false;
}
}
return true;
}
export function isNestServerRunning(): boolean {
return app !== null;
}
export async function getNestServerStatus(): Promise<{
running: boolean;
port: number;
base_url: string;
active_accounts: number;
}> {
const running = isNestServerRunning();
let activeAccounts = 0;
if (app) {
try {
const tokenManager = app.get(TokenManagerService);
activeAccounts = tokenManager.getAccountCount();
} catch (e) {
// TokenManager might not be available
}
}
return {
running,
port: currentPort,
base_url: running ? `http://localhost:${currentPort}` : '',
active_accounts: activeAccounts,
};
}
@@ -0,0 +1,112 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
@Injectable()
export class GeminiClient {
private readonly logger = new Logger(GeminiClient.name);
// Default to v1beta for most features
private readonly baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
async streamGenerate(model: string, content: GeminiRequest, accessToken: string): Promise<any> {
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
try {
const response = await axios.post(url, content, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
responseType: 'stream',
timeout: 60000,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(`Gemini Stream API Error: ${error.message}`);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
}
}
async generate(
model: string,
content: GeminiRequest,
accessToken: string,
): Promise<GeminiResponse> {
const url = `${this.baseUrl}/models/${model}:generateContent`;
try {
const response = await axios.post<GeminiResponse>(url, content, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
timeout: 60000, // 60s timeout
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
}
}
// --- Antigravity Internal API Support ---
private readonly internalBaseUrl = 'https://cloudcode-pa.googleapis.com/v1internal';
async streamGenerateInternal(body: GeminiInternalRequest, accessToken: string): Promise<any> {
const url = `${this.internalBaseUrl}:streamGenerateContent?alt=sse`;
try {
const response = await axios.post(url, body, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.11.9 windows/amd64',
},
responseType: 'stream',
timeout: 60000,
});
return response.data;
} catch (error) {
this.handleAxiosError(error, 'StreamInternal');
throw error;
}
}
async generateInternal(body: GeminiInternalRequest, accessToken: string): Promise<any> {
const url = `${this.internalBaseUrl}:generateContent`;
try {
const response = await axios.post(url, body, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.11.9 windows/amd64',
},
timeout: 60000,
});
// v1internal API wraps the response in a 'response' field, unwrap it
return response.data.response || response.data;
} catch (error) {
this.handleAxiosError(error, 'GenerateInternal');
throw error;
}
}
private handleAxiosError(error: any, context: string) {
if (axios.isAxiosError(error)) {
this.logger.error(
`Gemini ${context} API Error: ${error.message} - ${JSON.stringify(error.response?.data)}`,
);
throw new Error(error.response?.data?.error?.message || error.message);
}
throw error;
}
}
@@ -0,0 +1,141 @@
export interface OpenAIChatRequest {
model: string;
messages: OpenAIMessage[];
temperature?: number;
top_p?: number;
max_tokens?: number;
stream?: boolean;
size?: string;
quality?: string;
extra?: Record<string, any>;
}
export interface OpenAIMessage {
role: string;
content: string | OpenAIContentPart[];
name?: string;
}
export interface OpenAIContentPart {
type: 'text' | 'image_url';
text?: string;
image_url?: {
url: string;
};
}
export interface AnthropicChatRequest {
model: string;
messages: AnthropicMessage[];
system?: string | AnthropicSystemBlock[];
max_tokens?: number;
metadata?: any;
stop_sequences?: string[];
stream?: boolean;
temperature?: number;
top_p?: number;
top_k?: number;
}
export interface AnthropicMessage {
role: string;
content: string | AnthropicContent[];
}
export interface AnthropicSystemBlock {
type: string;
text: string;
}
export type AnthropicContent =
| { type: 'text'; text: string }
| { type: 'thinking'; thinking: string; signature?: string }
| { type: 'image'; source: AnthropicImageSource };
export interface AnthropicImageSource {
type: 'base64';
media_type: string;
data: string;
}
export interface GeminiContent {
role: string;
parts: GeminiPart[];
}
export interface GeminiPart {
text?: string;
inlineData?: GeminiInlineData;
thoughtSignature?: string;
}
export interface GeminiInlineData {
mimeType: string;
data: string;
}
export interface GeminiRequest {
contents: GeminiContent[];
systemInstruction?: { parts: GeminiPart[] };
generationConfig?: GeminiGenerationConfig;
}
export interface GeminiGenerationConfig {
temperature?: number;
maxOutputTokens?: number;
topP?: number;
topK?: number;
}
export interface GeminiResponse {
candidates?: GeminiCandidate[];
usageMetadata?: GeminiUsageMetadata;
}
export interface GeminiCandidate {
content?: GeminiContent;
finishReason?: string;
index?: number;
}
export interface GeminiUsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
}
export interface OpenAIChatResponse {
id: string;
object: string;
created: number;
model: string;
choices: OpenAIChoice[];
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface OpenAIChoice {
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}
export interface AnthropicChatResponse {
id: string;
type: string;
role: string;
model: string;
content: AnthropicContent[];
stop_reason: string | null;
stop_sequence: string | null;
usage: {
input_tokens: number;
output_tokens: number;
};
}
@@ -0,0 +1,92 @@
import { Controller, Get, Post, Body, Res, HttpStatus, UseGuards, Inject } from '@nestjs/common';
import { FastifyReply } from 'fastify';
import { ProxyService } from './proxy.service';
import { Observable } from 'rxjs';
import { OpenAIChatRequest, AnthropicChatRequest } from './interfaces/request-interfaces';
import { ProxyGuard } from './proxy.guard';
@Controller('v1')
@UseGuards(ProxyGuard)
export class ProxyController {
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) {}
@Get('models')
getModels() {
return {
object: 'list',
data: [
// Gemini Native
{
id: 'gemini-2.5-flash-thinking',
object: 'model',
created: 1734336000,
owned_by: 'google',
},
{ id: 'gemini-2.5-flash', object: 'model', created: 1734336000, owned_by: 'google' },
{ id: 'gemini-2.5-pro', object: 'model', created: 1734336000, owned_by: 'google' },
{ id: 'gemini-3-flash', object: 'model', created: 1734336000, owned_by: 'google' },
// Claude Native (Mapped)
{ id: 'claude-sonnet-4-5', object: 'model', created: 1734336000, owned_by: 'anthropic' },
{
id: 'claude-sonnet-4-5-thinking',
object: 'model',
created: 1734336000,
owned_by: 'anthropic',
},
// Image Models
{ id: 'gemini-3-pro-image', object: 'model', created: 1734336000, owned_by: 'google' },
],
};
}
@Post('chat/completions')
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
try {
const result = await this.proxyService.handleChatCompletions(body);
if (body.stream && result instanceof Observable) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
res.header('Connection', 'keep-alive');
res.send(result);
} else {
res.status(HttpStatus.OK).send(result);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal Server Error';
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
error: {
message: message,
type: 'server_error',
},
});
}
}
@Post('messages')
async anthropicMessages(@Body() body: AnthropicChatRequest, @Res() res: FastifyReply) {
try {
const result = await this.proxyService.handleAnthropicMessages(body);
if (body.stream && result instanceof Observable) {
res.header('Content-Type', 'text/event-stream');
res.header('Cache-Control', 'no-cache');
res.header('Connection', 'keep-alive');
res.send(result);
} else {
res.status(HttpStatus.OK).send(result);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal Server Error';
res.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
type: 'error',
error: {
type: 'api_error',
message: message,
},
});
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { getServerConfig } from '../../server-config';
@Injectable()
export class ProxyGuard implements CanActivate {
private readonly logger = new Logger(ProxyGuard.name);
canActivate(context: ExecutionContext): boolean {
const config = getServerConfig();
// 1. Check for API Key in config
const apiKey = config?.api_key;
// 2. Bypass if no api_key set (Open Mode) or config missing
if (!apiKey || apiKey.trim() === '') {
return true;
}
const request = context.switchToHttp().getRequest();
const headers = request.headers;
// 3. Extract Token from Headers
let clientToken: string | undefined;
// A. standard 'authorization: Bearer <token>'
if (headers['authorization']) {
const parts = headers['authorization'].split(' ');
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
clientToken = parts[1];
}
}
// B. 'x-api-key' (Anthropic)
if (!clientToken && headers['x-api-key']) {
clientToken = headers['x-api-key'] as string;
}
// C. 'x-goog-api-key' (Gemini)
if (!clientToken && headers['x-goog-api-key']) {
clientToken = headers['x-goog-api-key'] as string;
}
// 4. Validate
if (clientToken === apiKey) {
return true;
}
this.logger.warn(`Blocked unauthorized access from ${request.ip}`);
throw new UnauthorizedException('Invalid API Key');
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ProxyController } from './proxy.controller';
import { ProxyService } from './proxy.service';
import { TokenManagerService } from './token-manager.service';
import { GeminiClient } from './clients/gemini.client';
@Module({
controllers: [ProxyController],
providers: [ProxyService, TokenManagerService, GeminiClient],
})
export class ProxyModule {}
+568
View File
@@ -0,0 +1,568 @@
import { Injectable, Logger, Inject } from '@nestjs/common';
import { TokenManagerService } from './token-manager.service';
import { GeminiClient } from './clients/gemini.client';
import { v4 as uuidv4 } from 'uuid';
import { Observable } from 'rxjs';
import { transformClaudeRequestIn } from '../../../lib/antigravity/ClaudeRequestMapper';
import { transformResponse } from '../../../lib/antigravity/ClaudeResponseMapper';
import { StreamingState, PartProcessor } from '../../../lib/antigravity/ClaudeStreamingMapper';
import { ClaudeRequest } from '../../../lib/antigravity/types';
import {
OpenAIChatRequest,
AnthropicChatRequest,
GeminiContent,
GeminiPart,
AnthropicSystemBlock,
OpenAIContentPart,
GeminiResponse,
GeminiRequest,
AnthropicChatResponse,
OpenAIChatResponse,
GeminiCandidate,
} from './interfaces/request-interfaces';
@Injectable()
export class ProxyService {
private readonly logger = new Logger(ProxyService.name);
constructor(
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
@Inject(GeminiClient) private readonly geminiClient: GeminiClient,
) {}
// --- Anthropic Handlers ---
async handleAnthropicMessages(
request: AnthropicChatRequest,
): Promise<AnthropicChatResponse | Observable<string>> {
const targetModel = this.mapModel(request.model);
this.logger.log(
`Received Anthropic request for model: ${request.model} (Mapped: ${targetModel}, Stream: ${request.stream})`,
);
// Retry loop
let lastError: unknown = null;
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
const token = await this.tokenManager.getNextToken();
if (!token) {
throw new Error('No available accounts');
}
try {
// Use Antigravity Mapper
const projectId = token.token.project_id!;
const geminiBody = transformClaudeRequestIn(request as unknown as ClaudeRequest, projectId);
// Antigravity mapper determines the final model (e.g. gemini-2.5-flash) based on input
// We trust its judgment (it handles logic for online/image models)
if (request.stream) {
const stream = await this.geminiClient.streamGenerateInternal(
geminiBody,
token.token.access_token,
);
return this.processAnthropicInternalStream(stream, geminiBody.model);
} else {
const response = await this.geminiClient.generateInternal(
geminiBody,
token.token.access_token,
);
return transformResponse(response) as unknown as AnthropicChatResponse;
}
} catch (error) {
lastError = error;
if (error instanceof Error) {
this.logger.warn(`Anthropic Request failed: ${error.message}`);
if (this.shouldRetry(error.message)) {
this.tokenManager.markAsRateLimited(token.email);
}
}
}
}
throw lastError || new Error('Request failed after retries');
}
// Old converter kept for reference or other uses
private convertAnthropicToGemini(request: AnthropicChatRequest): GeminiRequest {
const contents: GeminiContent[] = [];
let systemInstruction: { parts: GeminiPart[] } | undefined = undefined;
if (request.system) {
let systemText = '';
if (typeof request.system === 'string') {
systemText = request.system;
} else if (Array.isArray(request.system)) {
systemText = request.system
.filter((b: AnthropicSystemBlock) => b.type === 'text')
.map((b: AnthropicSystemBlock) => b.text)
.join('\n');
}
systemInstruction = {
parts: [{ text: systemText }],
};
}
// Anthropic 'messages' -> Gemini 'contents'
for (const msg of request.messages) {
const role = msg.role === 'user' ? 'user' : 'model';
// Handle content arrays or strings
let textPart = '';
if (Array.isArray(msg.content)) {
textPart = msg.content
.filter((c) => c.type === 'text')
.map((c) => (c as { type: 'text'; text: string }).text)
.join('\n');
} else {
textPart = msg.content;
}
contents.push({
role,
parts: [{ text: textPart }],
});
}
return {
contents,
systemInstruction,
generationConfig: {
temperature: request.temperature,
maxOutputTokens: request.max_tokens,
topP: request.top_p,
topK: request.top_k,
},
};
}
// Old response converter
private convertGeminiToAnthropicResponse(
geminiResponse: GeminiResponse,
model: string,
): AnthropicChatResponse {
const candidate = geminiResponse.candidates?.[0];
const processed = this.processInlineData(candidate);
const parts = processed?.content?.parts || [];
const contentText = parts.map((p) => p.text || '').join('');
return {
id: `msg_${uuidv4()}`,
type: 'message',
role: 'assistant',
model: model,
content: [{ type: 'text', text: contentText }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
},
};
}
private processAnthropicInternalStream(upstreamStream: any, model: string): Observable<string> {
return new Observable<string>((subscriber) => {
const decoder = new TextDecoder();
let buffer = '';
const state = new StreamingState();
const processor = new PartProcessor(state);
let lastFinishReason: string | undefined;
let lastUsageMetadata: any | undefined;
upstreamStream.on('data', (chunk: Buffer) => {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
const dataStr = trimmed.slice(6);
if (dataStr === '[DONE]') continue;
try {
const json = JSON.parse(dataStr);
if (json) {
const startMsg = state.emitMessageStart(json);
if (startMsg) subscriber.next(startMsg);
}
const candidate = json.candidates?.[0];
const part = candidate?.content?.parts?.[0];
if (candidate?.finishReason) {
lastFinishReason = candidate.finishReason;
}
if (json.usageMetadata) {
lastUsageMetadata = json.usageMetadata;
}
if (part) {
const chunks = processor.process(part as any);
chunks.forEach((c) => subscriber.next(c));
}
} catch (e) {
this.logger.error('Stream parse error', e);
}
}
});
upstreamStream.on('end', () => {
const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata);
finishChunks.forEach((c) => subscriber.next(c));
subscriber.complete();
});
upstreamStream.on('error', (err: any) => subscriber.error(err));
});
}
// --- OpenAI / Universal Handlers ---
async handleChatCompletions(
request: OpenAIChatRequest,
): Promise<OpenAIChatResponse | Observable<string>> {
const targetModel = this.mapModel(request.model);
this.logger.log(
`Received request for model: ${request.model} (Mapped: ${targetModel}, Stream: ${request.stream})`,
);
// Retry loop for account selection
let lastError: unknown = null;
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
// 1. Get Token
const token = await this.tokenManager.getNextToken();
if (!token) {
throw new Error('No available accounts (all exhausted or rate limited)');
}
try {
// Convert OpenAI request to Claude format for Antigravity pipeline
const claudeRequest = this.convertOpenAIToClaude(request);
const projectId = token.token.project_id!;
const geminiBody = transformClaudeRequestIn(
claudeRequest as unknown as ClaudeRequest,
projectId,
);
// Use v1internal API (same as Anthropic handler)
if (request.stream) {
const stream = await this.geminiClient.streamGenerateInternal(
geminiBody,
token.token.access_token,
);
return this.processStreamResponse(stream, request.model);
} else {
const response = await this.geminiClient.generateInternal(
geminiBody,
token.token.access_token,
);
this.logger.log(`Raw Gemini Response: ${JSON.stringify(response).substring(0, 500)}`);
// Transform Gemini response to OpenAI format
const claudeResponse = transformResponse(response);
this.logger.log(`Claude Response: ${JSON.stringify(claudeResponse).substring(0, 500)}`);
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
}
} catch (error) {
lastError = error;
if (error instanceof Error) {
this.logger.warn(`Request failed with account ${token.email}: ${error.message}`);
// Check for rate limit / auth errors
if (this.shouldRetry(error.message)) {
this.tokenManager.markAsRateLimited(token.email);
}
}
}
}
throw lastError || new Error('Request failed after retries');
}
// Handle SSE Stream conversion
private processStreamResponse(upstreamStream: any, model: string): Observable<string> {
return new Observable<string>((subscriber) => {
const decoder = new TextDecoder();
let buffer = '';
upstreamStream.on('data', (chunk: Buffer) => {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
const dataStr = trimmed.slice(6);
if (dataStr === '[DONE]') continue;
try {
const json = JSON.parse(dataStr);
const candidate = json.candidates?.[0];
const contentPart = candidate?.content?.parts?.[0];
const text = contentPart?.text || '';
const openAICheck = {
id: `chatcmpl-${uuidv4()}`,
object: 'chat.completion.chunk',
created: Math.floor(Date.now() / 1000),
model: model,
choices: [
{
index: 0,
delta: { content: text },
finish_reason: candidate?.finishReason ? 'stop' : null,
},
],
};
subscriber.next(`data: ${JSON.stringify(openAICheck)}\n\n`);
if (candidate?.finishReason) {
subscriber.next('data: [DONE]\n\n');
subscriber.complete();
}
} catch (e) {
// ignore parse errors
}
}
});
upstreamStream.on('end', () => {
subscriber.complete();
});
upstreamStream.on('error', (err: any) => {
subscriber.error(err);
});
});
}
private convertOpenAIToGemini(request: OpenAIChatRequest): GeminiRequest {
const contents: GeminiContent[] = [];
let systemInstruction: { parts: GeminiPart[] } | undefined = undefined;
// Extract system message
const messages = request.messages || [];
for (const msg of messages) {
if (msg.role === 'system') {
if (typeof msg.content === 'string') {
systemInstruction = {
parts: [{ text: msg.content }],
};
}
} else {
let textPart = '';
if (Array.isArray(msg.content)) {
// OpenAI multimodal array
textPart = msg.content
.filter((p: OpenAIContentPart) => p.type === 'text')
.map((p: OpenAIContentPart) => p.text || '')
.join('\n');
} else {
textPart = msg.content;
}
contents.push({
role: msg.role === 'user' ? 'user' : 'model',
parts: [{ text: textPart }],
});
}
}
return {
contents,
systemInstruction,
generationConfig: {
temperature: request.temperature,
maxOutputTokens: request.max_tokens,
topP: request.top_p,
},
};
}
private convertGeminiToOpenAIResponse(
geminiResponse: GeminiResponse,
model: string,
): OpenAIChatResponse {
const candidate = geminiResponse.candidates?.[0];
if (!candidate) throw new Error('No candidates in response');
// Process response for images
const processedCandidate = this.processInlineData(candidate);
const parts = processedCandidate?.content?.parts || [];
const content = parts.map((p) => p.text || '').join('');
const finishReason =
candidate?.finishReason === 'STOP'
? 'stop'
: candidate?.finishReason?.toLowerCase() || 'stop';
return {
id: `chatcmpl-${uuidv4()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: content,
},
finish_reason: finishReason,
},
],
usage: {
prompt_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
completion_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0,
total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0,
},
};
}
// Convert Gemini inlineData (images) to Markdown
private processInlineData(candidate: GeminiCandidate | undefined): GeminiCandidate | undefined {
if (!candidate?.content?.parts) return candidate;
const newParts = candidate.content.parts.map((part) => {
if (part.inlineData) {
const mimeType = part.inlineData.mimeType || 'image/jpeg';
const data = part.inlineData.data || '';
return {
text: `\n\n![Generated Image](data:${mimeType};base64,${data})\n\n`,
};
}
return part;
});
return {
...candidate,
content: {
...candidate.content,
parts: newParts,
},
};
}
// Convert OpenAI request format to Claude/Anthropic format
private convertOpenAIToClaude(request: OpenAIChatRequest): AnthropicChatRequest {
const messages = request.messages || [];
let systemPrompt: string | undefined;
const anthropicMessages: { role: string; content: string }[] = [];
for (const msg of messages) {
if (msg.role === 'system') {
// Extract system message
if (typeof msg.content === 'string') {
systemPrompt = msg.content;
}
} else {
let textContent = '';
if (Array.isArray(msg.content)) {
textContent = msg.content
.filter((p: OpenAIContentPart) => p.type === 'text')
.map((p: OpenAIContentPart) => p.text || '')
.join('\n');
} else {
textContent = msg.content;
}
anthropicMessages.push({
role: msg.role === 'assistant' ? 'assistant' : 'user',
content: textContent,
});
}
}
return {
model: request.model,
messages: anthropicMessages,
system: systemPrompt,
max_tokens: request.max_tokens || 4096,
temperature: request.temperature,
top_p: request.top_p,
stream: request.stream,
};
}
// Convert Claude response to OpenAI format
private convertClaudeToOpenAIResponse(claudeResponse: any, model: string): OpenAIChatResponse {
// Extract text content from Claude response
const content =
claudeResponse.content
?.filter((block: any) => block.type === 'text')
.map((block: any) => block.text)
.join('') || '';
const finishReason =
claudeResponse.stop_reason === 'end_turn'
? 'stop'
: claudeResponse.stop_reason === 'max_tokens'
? 'length'
: 'stop';
return {
id: `chatcmpl-${uuidv4()}`,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: content,
},
finish_reason: finishReason,
},
],
usage: {
prompt_tokens: claudeResponse.usage?.input_tokens || 0,
completion_tokens: claudeResponse.usage?.output_tokens || 0,
total_tokens:
(claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0),
},
};
}
private mapModel(model: string): string {
const lower = model.toLowerCase();
if (lower.includes('sonnet') || lower.includes('thinking')) return 'gemini-3-pro-preview';
if (lower.includes('haiku')) return 'gemini-2.0-flash-exp';
if (lower.includes('opus')) return 'gemini-3-pro-preview';
if (lower.includes('claude')) return 'gemini-2.5-flash-thinking';
if (lower === 'gemini-3-pro-high' || lower === 'gemini-3-pro-low')
return 'gemini-3-pro-preview';
if (lower === 'gemini-3-flash') return 'gemini-3-flash-preview';
// Default to original if no mapping found
return model;
}
private shouldRetry(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase();
if (
msg.includes('404') ||
msg.includes('not_found') ||
msg.includes('403') ||
msg.includes('permission_denied')
)
return true;
if (
msg.includes('429') ||
msg.includes('resource_exhausted') ||
msg.includes('quota') ||
msg.includes('rate_limit')
)
return true;
return false;
}
}
@@ -0,0 +1,201 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { CloudAccountRepo } from '../../../ipc/database/cloudHandler';
import { CloudAccount } from '../../../types/cloudAccount';
import { GoogleAPIService } from '../../../services/GoogleAPIService';
interface TokenData {
email: string;
account_id: string;
access_token: string;
refresh_token: string;
expires_in: number;
expiry_timestamp: number;
project_id?: string;
session_id?: string;
}
@Injectable()
export class TokenManagerService implements OnModuleInit {
private readonly logger = new Logger(TokenManagerService.name);
private currentIndex = 0;
// In-memory cache of tokens with additional data
private tokens: Map<string, TokenData> = new Map();
// Cooldown map for rate-limited accounts
private cooldowns: Map<string, number> = new Map();
async onModuleInit() {
// Load accounts on module initialization
await this.loadAccounts();
}
async loadAccounts(): Promise<number> {
try {
const accounts = await CloudAccountRepo.getAccounts();
let count = 0;
for (const account of accounts) {
const tokenData = this.convertAccountToToken(account);
if (tokenData) {
this.tokens.set(account.id, tokenData);
count++;
}
}
this.logger.log(`Loaded ${count} accounts`);
return count;
} catch (e) {
this.logger.error('Failed to load accounts', e);
return 0;
}
}
private convertAccountToToken(account: CloudAccount): TokenData | null {
if (!account.token) return null;
return {
account_id: account.id,
email: account.email,
access_token: account.token.access_token,
refresh_token: account.token.refresh_token,
expires_in: account.token.expires_in,
expiry_timestamp: account.token.expiry_timestamp,
project_id: account.token.project_id || undefined,
session_id: account.token.session_id || this.generateSessionId(),
};
}
private generateSessionId(): string {
const min = 1_000_000_000_000_000_000n;
const max = 9_000_000_000_000_000_000n;
const range = max - min;
const rand = BigInt(Math.floor(Math.random() * Number(range)));
return (-(min + rand)).toString();
}
async getNextToken(): Promise<CloudAccount | null> {
try {
// Reload if empty
if (this.tokens.size === 0) {
await this.loadAccounts();
}
if (this.tokens.size === 0) return null;
const now = Date.now();
const nowSeconds = Math.floor(now / 1000);
// Filter out accounts in cooldown
const validTokens = Array.from(this.tokens.entries()).filter(([email, _]) => {
const cooldownUntil = this.cooldowns.get(email);
return !cooldownUntil || cooldownUntil <= now;
});
if (validTokens.length === 0) {
this.logger.warn('All accounts are in cooldown');
return null;
}
// Round robin selection
const [accountId, tokenData] = validTokens[this.currentIndex % validTokens.length];
this.currentIndex++;
// Check if token needs refresh (expires in < 5 minutes)
if (nowSeconds >= tokenData.expiry_timestamp - 300) {
this.logger.log(`Token for ${tokenData.email} expiring soon, refreshing...`);
try {
const newTokens = await GoogleAPIService.refreshAccessToken(tokenData.refresh_token);
// Update token data
tokenData.access_token = newTokens.access_token;
tokenData.expires_in = newTokens.expires_in;
tokenData.expiry_timestamp = nowSeconds + newTokens.expires_in;
// Save to DB
await this.saveRefreshedToken(accountId, tokenData);
this.tokens.set(accountId, tokenData);
this.logger.log(`Token refreshed for ${tokenData.email}`);
} catch (e) {
this.logger.error(`Failed to refresh token for ${tokenData.email}`, e);
}
}
// Resolve project ID if missing (mock for now, like original)
if (!tokenData.project_id) {
const mockId = `cloud-code-${Math.floor(Math.random() * 100000)}`;
tokenData.project_id = mockId;
await this.saveProjectId(accountId, mockId);
}
this.logger.log(`Selected account: ${tokenData.email}`);
// Return in CloudAccount format for compatibility
return {
id: accountId,
email: tokenData.email,
token: {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
expires_in: tokenData.expires_in,
expiry_timestamp: tokenData.expiry_timestamp,
project_id: tokenData.project_id,
session_id: tokenData.session_id,
},
} as CloudAccount;
} catch (error) {
this.logger.error('Failed to get token', error);
return null;
}
}
markAsRateLimited(email: string) {
// Cooldown for 5 minutes
const until = Date.now() + 5 * 60 * 1000;
this.cooldowns.set(email, until);
this.logger.warn(
`Account ${email} marked as rate limited until ${new Date(until).toISOString()}`,
);
}
resetCooldown(email: string) {
this.cooldowns.delete(email);
}
private async saveRefreshedToken(accountId: string, tokenData: TokenData) {
try {
const acc = await CloudAccountRepo.getAccount(accountId);
if (acc && acc.token) {
const newToken = {
...acc.token,
access_token: tokenData.access_token,
expires_in: tokenData.expires_in,
expiry_timestamp: tokenData.expiry_timestamp,
};
await CloudAccountRepo.updateToken(accountId, newToken);
}
} catch (e) {
this.logger.error('Failed to save refreshed token to DB', e);
}
}
private async saveProjectId(accountId: string, projectId: string) {
try {
const acc = await CloudAccountRepo.getAccount(accountId);
if (acc && acc.token) {
const newToken = {
...acc.token,
project_id: projectId,
};
await CloudAccountRepo.updateToken(accountId, newToken);
}
} catch (e) {
this.logger.error('Failed to save project ID to DB', e);
}
}
/**
* Get the number of loaded accounts (for status)
*/
getAccountCount(): number {
return this.tokens.size;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { ProxyConfig } from '../types/config';
let serverConfig: ProxyConfig | null = null;
export function setServerConfig(config: ProxyConfig) {
serverConfig = config;
}
export function getServerConfig(): ProxyConfig | null {
return serverConfig;
}
+1 -1
View File
@@ -8,7 +8,7 @@ export const UpstreamProxyConfigSchema = z.object({
export const ProxyConfigSchema = z.object({
enabled: z.boolean(), // 是否启用
port: z.number(), // 监听端口
api_key: z.string(), // API 密钥
api_key: z.string(), // API 密钥 (Also used for Admin Mode auth)
auto_start: z.boolean(), // 是否自动启动
anthropic_mapping: z.record(z.string(), z.string()), // 映射表
request_timeout: z.number().default(120), // 超时秒数
+1
View File
@@ -5,6 +5,7 @@
"module": "ESNext",
"lib": ["dom", "ESNext"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"composite": true,
"declaration": true,
"forceConsistentCasingInFileNames": true,
+8
View File
@@ -6,6 +6,14 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(process.cwd(), './src'),
kafkajs: path.resolve(process.cwd(), './src/mocks/empty.ts'),
mqtt: path.resolve(process.cwd(), './src/mocks/empty.ts'),
amqplib: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'amqp-connection-manager': path.resolve(process.cwd(), './src/mocks/empty.ts'),
nats: path.resolve(process.cwd(), './src/mocks/empty.ts'),
ioredis: path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/static': path.resolve(process.cwd(), './src/mocks/empty.ts'),
'@fastify/view': path.resolve(process.cwd(), './src/mocks/empty.ts'),
},
},
build: {