Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0223e8d807 | ||
|
|
1fd0fde700 | ||
|
|
2c1755e578 | ||
|
|
3100db93ed | ||
|
|
e9c4f72883 | ||
|
|
87c17a1ed0 | ||
|
|
4fa0a3c63d | ||
|
|
388fbcb2a9 | ||
|
|
bcdf2a6c24 | ||
|
|
385036d577 | ||
|
|
f970ccb2ed | ||
|
|
fce38ec6f0 | ||
|
|
61716bb512 | ||
|
|
ed0dd811a8 | ||
|
|
50a325be11 | ||
|
|
20fffff29c | ||
|
|
c95f494569 | ||
|
|
5401e40943 | ||
|
|
0a60c0eafa | ||
|
|
30e38dcb8b |
No files matched your search
@@ -123,3 +123,6 @@ build/
|
||||
cli/aliases.json
|
||||
*.backup
|
||||
accounts_backup.json
|
||||
|
||||
# Bun lock file
|
||||
bun.lock
|
||||
Vendored
+24
@@ -12,6 +12,30 @@
|
||||
"outFiles": ["${workspaceFolder}/.vite/build/**/*.js"],
|
||||
"console": "integratedTerminal",
|
||||
"autoAttachChildProcesses": true
|
||||
},
|
||||
{
|
||||
"name": "Debug Main Process (Proxyman)",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "cmd.exe",
|
||||
"runtimeArgs": ["/c", "npm", "run", "start"],
|
||||
"sourceMaps": true,
|
||||
"outFiles": ["${workspaceFolder}/.vite/build/**/*.js"],
|
||||
"console": "integratedTerminal",
|
||||
"autoAttachChildProcesses": true,
|
||||
"env": {
|
||||
"NODE_USE_ENV_PROXY": "1",
|
||||
"HTTP_PROXY": "http://127.0.0.1:9090",
|
||||
"HTTPS_PROXY": "http://127.0.0.1:9090",
|
||||
"ALL_PROXY": "http://127.0.0.1:9090",
|
||||
"NO_PROXY": "localhost,127.0.0.1,::1",
|
||||
"ELECTRON_PROXY_SERVER": "http://127.0.0.1:9090",
|
||||
"ELECTRON_PROXY_BYPASS_LIST": "<local>;localhost;127.0.0.1;::1",
|
||||
"DEBUG_PROXY_TRACE": "1",
|
||||
"NODE_EXTRA_CA_CERTS": "C:\\Users\\Administrator\\AppData\\Roaming\\Proxyman\\certificate\\certs\\ca.pem",
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,6 +2,36 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [0.11.0](https://github.com/Draculabo/AntigravityManager/compare/v0.10.0...v0.11.0) (2026-04-11)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* account management overhaul + 7 issue resolutions ([#147](https://github.com/Draculabo/AntigravityManager/issues/147)) ([ed0dd81](https://github.com/Draculabo/AntigravityManager/commit/ed0dd811a8958928a2702839db07444e89f9fdb2)), closes [#79](https://github.com/Draculabo/AntigravityManager/issues/79) [#126](https://github.com/Draculabo/AntigravityManager/issues/126) [#99](https://github.com/Draculabo/AntigravityManager/issues/99) [#145](https://github.com/Draculabo/AntigravityManager/issues/145) [#134](https://github.com/Draculabo/AntigravityManager/issues/134) [#53](https://github.com/Draculabo/AntigravityManager/issues/53) [#117](https://github.com/Draculabo/AntigravityManager/issues/117)
|
||||
* **antigravity:** add model specs and update mapping logic ([0a60c0e](https://github.com/Draculabo/AntigravityManager/commit/0a60c0eafa698202722be9c283cb52831e2e0179))
|
||||
* **cloud-oauth:** support selectable google oauth clients ([bcdf2a6](https://github.com/Draculabo/AntigravityManager/commit/bcdf2a6c24fc992d32b4ae883f2a34c8e64969e3))
|
||||
* **cloud-status:** classify blocked accounts and surface validation state ([388fbcb](https://github.com/Draculabo/AntigravityManager/commit/388fbcb2a9522285eb0bb4a541b48cd3e1049470))
|
||||
* **cloud-sync:** persist project and status metadata for synced accounts ([385036d](https://github.com/Draculabo/AntigravityManager/commit/385036d577495caeebf95301542811b08c9f333e))
|
||||
* **protobuf:** add unified state helpers for oauth payloads ([f970ccb](https://github.com/Draculabo/AntigravityManager/commit/f970ccb2ed1f3a3e9de09221895f0b650c1115b9))
|
||||
* **proxy:** add upstream error and rate-limit tracking support ([20fffff](https://github.com/Draculabo/AntigravityManager/commit/20fffff29c47d1147e9b429884d9e8b613e7da6f))
|
||||
* **proxy:** improve parity, rate limiting, and token scheduling ([5401e40](https://github.com/Draculabo/AntigravityManager/commit/5401e40943824d54bc95971229a83934e55ad88f))
|
||||
* **proxy:** improve quota fallback and proxy parity handling ([4fa0a3c](https://github.com/Draculabo/AntigravityManager/commit/4fa0a3c63dd4b973d0d07feb7351831ad827176d))
|
||||
* **ui:** update cloud account and provider visibility experience ([c95f494](https://github.com/Draculabo/AntigravityManager/commit/c95f494569b49ff1fae871970764c9f0a3fbda59))
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* **cloud:** add missing oauth action bindings and validation status helper ([87c17a1](https://github.com/Draculabo/AntigravityManager/commit/87c17a1ed0856572f0f8c46ede923b563b726108))
|
||||
* **cloud:** fetch AI credits on startup with endpoint fallback and preserve cached credits ([61716bb](https://github.com/Draculabo/AntigravityManager/commit/61716bb51259c0a5899b8659aa864c5772b75cbb))
|
||||
* **linux:** dynamic executable path detection ([#121](https://github.com/Draculabo/AntigravityManager/issues/121)) ([30e38dc](https://github.com/Draculabo/AntigravityManager/commit/30e38dcb8b9329b68601c9df896fd815ccadc1ec))
|
||||
* **linux:** harden account-switch relaunch against GPU process crashes ([2c1755e](https://github.com/Draculabo/AntigravityManager/commit/2c1755e578ac91962e813a141ec7d3df6fa61a74))
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
* **proxyman:** add setup and debugging guides and link them from readmes ([3100db9](https://github.com/Draculabo/AntigravityManager/commit/3100db93ed92f9f8ef19aceae49a699768e20ca9))
|
||||
|
||||
### ✅ Tests
|
||||
|
||||
* **proxy:** refine signature test text ([50a325b](https://github.com/Draculabo/AntigravityManager/commit/50a325be113ef59dd9d0144b84d64f9eb40ea67a))
|
||||
|
||||
## [0.10.0](https://github.com/Draculabo/AntigravityManager/compare/v0.9.2...v0.10.0) (2026-02-19)
|
||||
|
||||
### ✨ Features
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Tech Stack](#️-tech-stack)
|
||||
- [Development](#-development)
|
||||
- [Debugging Docs](#-debugging-docs)
|
||||
- [FAQ](#-faq)
|
||||
- [Contributing](#-contributing)
|
||||
- [License](#-license)
|
||||
@@ -158,7 +159,12 @@ When using Antigravity IDE, have you ever encountered these problems?
|
||||
- **🖥️ 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 / 中文)
|
||||
- **📤 JSON Export/Import** - Export and import account pools with schema validation and deduplication
|
||||
- **🔔 Desktop Notifications** - Configurable alerts when model quota drops below your threshold
|
||||
- **🌐 Per-Account Proxy** - Route individual accounts through their own HTTP/SOCKS5 proxy
|
||||
- **📊 Smart Sorting** - Sort accounts by recently used, overall quota, or specific model groups
|
||||
- **📋 Compact Layout** - Dense horizontal view to maximize visible accounts
|
||||
- **🌏 Internationalization** - Multi-language support (English / 中文 / Русский)
|
||||
- **🎨 Modern UI** - Built with React, TailwindCSS, and Shadcn UI
|
||||
|
||||
---
|
||||
@@ -188,6 +194,53 @@ Download the latest release for your platform from the [Releases](https://github
|
||||
| Windows (x64/ARM64) | [.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) |
|
||||
| NixOS / Nix | `nix run github:Draculabo/AntigravityManager` |
|
||||
|
||||
### ❄️ Nix Integration
|
||||
|
||||
You can integrate Antigravity Manager into your Nix configuration using the provided flake.
|
||||
|
||||
#### 🛠️ NixOS / Home Manager
|
||||
|
||||
Add the flake to your `flake.nix` inputs:
|
||||
|
||||
```nix
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
antigravity-manager.url = "github:Draculabo/AntigravityManager";
|
||||
};
|
||||
```
|
||||
|
||||
To integrate with your system, add `antigravity-manager.overlays.default` to your `nixpkgs.overlays`. This ensures the package is correctly built against your own `nixpkgs` instance and respects your global configuration.
|
||||
|
||||
```nix
|
||||
# Add to your NixOS/Home Manager configuration
|
||||
{
|
||||
nixpkgs.overlays = [ inputs.antigravity-manager.overlays.default ];
|
||||
|
||||
# Antigravity Manager has an unfree license (cc-by-nc-sa-40)
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
environment.systemPackages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 💻 Development Shell
|
||||
|
||||
If you're using the overlay above, you can simply add it to your `devShell`:
|
||||
|
||||
```nix
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
};
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Since the package is unfree, you **must** have `allowUnfree = true;` enabled in your `nixpkgs` configuration for it to evaluate correctly.
|
||||
|
||||
### Build from Source
|
||||
|
||||
@@ -259,6 +312,15 @@ AntigravityManager/
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Debugging Docs
|
||||
|
||||
If you want to inspect Antigravity or Antigravity Manager traffic with Proxyman, start here:
|
||||
|
||||
- [Proxyman Installation and Basic Setup on Windows](docs/proxyman-install.md)
|
||||
- [Proxyman Debugging Guide for Antigravity and Antigravity Manager](docs/proxyman-debugging.md)
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
|
||||
+63
-1
@@ -50,6 +50,7 @@
|
||||
- [快速开始](#-快速开始)
|
||||
- [技术栈](#️-技术栈)
|
||||
- [开发指南](#-开发指南)
|
||||
- [调试文档](#-调试文档)
|
||||
- [常见问题](#-常见问题)
|
||||
- [贡献指南](#-贡献指南)
|
||||
- [许可证](#-许可证)
|
||||
@@ -158,7 +159,12 @@
|
||||
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
||||
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
||||
- **📦 批量操作** - 批量刷新和删除多个账号
|
||||
- **🌏 国际化** - 多语言支持(English / 中文)
|
||||
- **📤 JSON 导出/导入** - 导出和导入账号池,支持架构验证和去重
|
||||
- **🔔 桌面通知** - 模型配额低于设定阈值时自动提醒
|
||||
- **🌐 单账号代理** - 为每个账号单独配置 HTTP/SOCKS5 代理
|
||||
- **📊 智能排序** - 按最近使用、总体配额或特定模型组排序
|
||||
- **📋 紧凑布局** - 水平密集视图,最大化可见账号数量
|
||||
- **🌏 国际化** - 多语言支持(English / 中文 / Русский)
|
||||
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
||||
|
||||
---
|
||||
@@ -187,6 +193,53 @@
|
||||
| Windows (x64/ARM64) | [.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) |
|
||||
| NixOS / Nix | `nix run github:Draculabo/AntigravityManager` |
|
||||
|
||||
### ❄️ Nix 集成
|
||||
|
||||
你可以通过项目提供的 flake 将 Antigravity Manager 集成到你的 Nix 配置中。
|
||||
|
||||
#### 🛠️ NixOS / Home Manager
|
||||
|
||||
先在你的 `flake.nix` 的 `inputs` 中加入:
|
||||
|
||||
```nix
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
antigravity-manager.url = "github:Draculabo/AntigravityManager";
|
||||
};
|
||||
```
|
||||
|
||||
然后把 `antigravity-manager.overlays.default` 添加到 `nixpkgs.overlays`。这样会基于你自己的 `nixpkgs` 实例构建,并继承你的全局配置。
|
||||
|
||||
```nix
|
||||
# 添加到你的 NixOS/Home Manager 配置中
|
||||
{
|
||||
nixpkgs.overlays = [ inputs.antigravity-manager.overlays.default ];
|
||||
|
||||
# Antigravity Manager 使用非自由许可证 (cc-by-nc-sa-40)
|
||||
nixpkgs.config.allowUnfree = true;
|
||||
|
||||
environment.systemPackages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 💻 开发 Shell
|
||||
|
||||
如果你已经使用上面的 overlay,也可以直接在 `devShell` 里加入:
|
||||
|
||||
```nix
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.antigravity-manager
|
||||
];
|
||||
};
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 由于该包是非自由软件,你必须在 `nixpkgs` 配置中开启 `allowUnfree = true;`,否则会评估失败。
|
||||
|
||||
### 从源码构建
|
||||
|
||||
@@ -258,6 +311,15 @@ AntigravityManager/
|
||||
|
||||
---
|
||||
|
||||
## 🔍 调试文档
|
||||
|
||||
如果你希望用 Proxyman 抓取和排查 Antigravity 或 Antigravity Manager 的流量,请先阅读:
|
||||
|
||||
- [Proxyman Installation and Basic Setup on Windows](docs/proxyman-install.md)
|
||||
- [Proxyman Debugging Guide for Antigravity and Antigravity Manager](docs/proxyman-debugging.md)
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
<details>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
# CLI Feature - Pull Request Summary
|
||||
|
||||
## What This PR Adds
|
||||
|
||||
A complete **Command-Line Interface (CLI)** for Antigravity Manager with 18+ commands for power users.
|
||||
|
||||
## Key Features
|
||||
|
||||
- 📊 **Live Quota Monitoring** - Real-time tracking for Gemini & Claude models
|
||||
- 🔄 **Account Switching** - Seamless switching without manual token management
|
||||
- ✅ **Token Validation** - Auto-refresh expired tokens
|
||||
- 🎯 **Smart Auto-Switch** - Automatically pick best account by quota
|
||||
- 🏷️ **Aliases** - Shortcut names for accounts (`work`, `personal`)
|
||||
- 💾 **Backup/Restore** - Export/import account configurations
|
||||
- 🎮 **Interactive Mode** - Keyboard-navigated menus for beginners
|
||||
- 🩺 **Diagnostics** - System health checks
|
||||
|
||||
## Technical Highlights
|
||||
|
||||
- **Zero Hardcoded Paths**: Intelligently discovers database and executable locations
|
||||
- **Cross-Platform Ready**: Uses environment variables and checks for Windows/Mac/Linux
|
||||
- **Secure**: Leverages existing DPAPI + AES-256-GCM encryption (same as GUI)
|
||||
- **Extensible**: Clean separation between core logic (`core.py`) and commands (`main.py`)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
cli/
|
||||
├── README.md # Full documentation
|
||||
├── requirements.txt # Python dependencies
|
||||
├── core.py # Database, encryption, API logic
|
||||
├── main.py # CLI commands and interactive UI
|
||||
└── proto_utils.py # Protobuf encoding helpers
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```powershell
|
||||
# Interactive mode
|
||||
agm
|
||||
|
||||
# Quick status
|
||||
agm status
|
||||
|
||||
# Switch accounts
|
||||
agm switch work
|
||||
|
||||
# Auto-switch to best account
|
||||
agm auto-switch --model claude --min-quota 50
|
||||
|
||||
# Live monitoring
|
||||
agm watch
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Tested on Windows 10/11
|
||||
- ✅ No hardcoded paths (uses standard Windows env vars)
|
||||
- ✅ Works with both installed and development builds
|
||||
- ✅ Database encryption/decryption verified
|
||||
|
||||
## Dependencies
|
||||
|
||||
All Python dependencies are self-contained in `cli/requirements.txt`:
|
||||
- `typer` - CLI framework
|
||||
- `rich` - Beautiful terminal output
|
||||
- `questionary` - Interactive menus
|
||||
- `pywin32` - Windows DPAPI support
|
||||
- `cryptography` - AES-256-GCM encryption
|
||||
- `httpx` - Async HTTP client
|
||||
- `psutil` - Process management
|
||||
- `protobuf` - Google API protocol buffers
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **Windows**: Full support (tested)
|
||||
- **macOS/Linux**: Partial support (no DPAPI, but core features work)
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None. This is a pure addition—no changes to existing GUI code.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- OAuth flow for adding new accounts via CLI
|
||||
- TUI (Terminal UI) with live dashboard
|
||||
- Scheduled auto-refresh via Windows Task Scheduler
|
||||
- Config file for user preferences
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Code follows existing style
|
||||
- [x] All commands have help text
|
||||
- [x] No hardcoded paths/credentials
|
||||
- [x] README with examples
|
||||
- [x] Dependencies in requirements.txt
|
||||
- [x] .gitignore updated for Python
|
||||
- [x] Works with fresh installs
|
||||
|
||||
---
|
||||
|
||||
**Ready to merge!** 🚀
|
||||
@@ -0,0 +1,244 @@
|
||||
# Proxyman Debugging Guide for Antigravity and Antigravity Manager
|
||||
|
||||
This guide explains how to use Proxyman for two debugging targets:
|
||||
|
||||
1. The installed official `Antigravity` desktop client
|
||||
2. The `Antigravity Manager` development build from this repository
|
||||
|
||||
Before you start, complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
|
||||
## Scope
|
||||
|
||||
Use this guide when you want to:
|
||||
|
||||
- inspect login, quota, project context, or model-list requests from `Antigravity`
|
||||
- inspect OAuth, quota refresh, or proxy traffic from `Antigravity Manager`
|
||||
- compare requests from the official client with requests produced by this project
|
||||
|
||||
## Understand the Two Traffic Paths
|
||||
|
||||
### Official `Antigravity` Client
|
||||
|
||||
`Antigravity` is an Electron application. Its traffic may come from:
|
||||
|
||||
- Chromium or renderer-side requests
|
||||
- Electron or Node-side requests
|
||||
|
||||
### `Antigravity Manager` in This Repository
|
||||
|
||||
This project is also an Electron app, but it additionally includes Node, Undici, and internal proxy flows. You may need to inspect:
|
||||
|
||||
- Electron main-process outbound requests
|
||||
- renderer-side requests
|
||||
- upstream requests emitted by `src/server/`
|
||||
|
||||
## Debug the Installed Official Antigravity Client
|
||||
|
||||
### Recommended Path: Use the Repository Script
|
||||
|
||||
Use the provided script:
|
||||
|
||||
- [scripts/start-antigravity-with-proxyman.ps1](../scripts/start-antigravity-with-proxyman.ps1)
|
||||
|
||||
This should be the default path for the installed official client because it already does the following:
|
||||
|
||||
- sets `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY`
|
||||
- sets `NODE_EXTRA_CA_CERTS`
|
||||
- passes `--proxy-server` and `--proxy-bypass-list`
|
||||
- stops existing `Antigravity` processes so the new environment takes effect
|
||||
|
||||
Typical usage:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1
|
||||
```
|
||||
|
||||
If Proxyman is using a different port:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1 -Port 9091
|
||||
```
|
||||
|
||||
If you are temporarily debugging TLS failures:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1 -AllowInsecureTls
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This script targets the installed official `Antigravity` client.
|
||||
- It does not launch this repository's `Antigravity Manager` development build.
|
||||
- Override `-AppPath`, `-CaPath`, `-ProxyHost`, or `-NoProxy` if your local environment differs.
|
||||
|
||||
### Suggested Flow
|
||||
|
||||
1. Open Proxyman.
|
||||
2. Confirm that installation and certificate setup are already complete.
|
||||
3. Confirm the local proxy port, for example `9090`.
|
||||
4. Launch `Antigravity` with the script.
|
||||
5. Trigger a request that is easy to identify, such as:
|
||||
- Google sign-in
|
||||
- model list loading
|
||||
- quota refresh
|
||||
- a generated request or prompt action
|
||||
|
||||
### Why Manual Parameters Are Still Documented
|
||||
|
||||
The script is the recommended default, but the raw parameters are still useful because:
|
||||
|
||||
- contributors may want to understand exactly what the script configures
|
||||
- some people launch from VS Code, another shell, or a custom wrapper
|
||||
- some debugging sessions need a partial override rather than the full script
|
||||
|
||||
### If Windows System Proxy Is Not Enough
|
||||
|
||||
In some environments you may still need to point the system proxy to Proxyman manually. At minimum, verify:
|
||||
|
||||
- HTTP proxy: `127.0.0.1:9090`
|
||||
- HTTPS proxy: `127.0.0.1:9090`
|
||||
- the bypass list is not excluding the domains you want to inspect
|
||||
|
||||
### If You Need Direct Chromium Proxy Arguments
|
||||
|
||||
If system proxy behavior is unreliable, you can launch the installed client with explicit Chromium proxy arguments:
|
||||
|
||||
```powershell
|
||||
& "C:\Users\<YourUser>\AppData\Local\Programs\Antigravity\Antigravity.exe" `
|
||||
--proxy-server="http://127.0.0.1:9090" `
|
||||
--proxy-bypass-list="<local>;localhost;127.0.0.1;::1"
|
||||
```
|
||||
|
||||
Use this as a fallback, not the default path.
|
||||
|
||||
## Debug Antigravity Manager From This Repository
|
||||
|
||||
This project has more layers than the official client. The script above is not the general launcher for this repository's development app.
|
||||
|
||||
### Capture Main-Process, Node, and Internal Proxy Traffic
|
||||
|
||||
Set proxy-related environment variables before starting the app:
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY = "http://127.0.0.1:9090"
|
||||
$env:HTTPS_PROXY = "http://127.0.0.1:9090"
|
||||
$env:ALL_PROXY = "http://127.0.0.1:9090"
|
||||
$env:NO_PROXY = "localhost,127.0.0.1,::1"
|
||||
$env:NODE_EXTRA_CA_CERTS = "$env:APPDATA\\Proxyman\\certificate\\certs\\ca.pem"
|
||||
|
||||
# Enable only for temporary TLS debugging
|
||||
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"
|
||||
|
||||
npm start
|
||||
```
|
||||
|
||||
This primarily affects:
|
||||
|
||||
- Node HTTP and HTTPS requests
|
||||
- `undici`, `fetch`, and some dependency-driven upstream requests
|
||||
- internal proxy requests forwarded to upstream services
|
||||
|
||||
### Also Capture Electron and Chromium Traffic
|
||||
|
||||
If you want both Node-side and renderer-side traffic, add Chromium proxy arguments:
|
||||
|
||||
```powershell
|
||||
$env:HTTP_PROXY = "http://127.0.0.1:9090"
|
||||
$env:HTTPS_PROXY = "http://127.0.0.1:9090"
|
||||
$env:ALL_PROXY = "http://127.0.0.1:9090"
|
||||
$env:NO_PROXY = "localhost,127.0.0.1,::1"
|
||||
$env:NODE_EXTRA_CA_CERTS = "$env:APPDATA\\Proxyman\\certificate\\certs\\ca.pem"
|
||||
$env:NODE_TLS_REJECT_UNAUTHORIZED = "0"
|
||||
|
||||
npm start -- --proxy-server="http://127.0.0.1:9090" --proxy-bypass-list="<local>;localhost;127.0.0.1;::1"
|
||||
```
|
||||
|
||||
If your local `npm start` flow does not forward arguments to Electron Forge, use one of these fallback approaches:
|
||||
|
||||
- capture only Node and main-process traffic
|
||||
- wrap the Electron launch in your own PowerShell or VS Code task
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### I Can See Requests but HTTPS Bodies Are Empty
|
||||
|
||||
Check:
|
||||
|
||||
- the Proxyman root certificate is really trusted
|
||||
- `NODE_EXTRA_CA_CERTS` is set when Node-side traffic needs it
|
||||
- `NODE_TLS_REJECT_UNAUTHORIZED=0` is only being used as a temporary debugging override
|
||||
- the target app is not using SSL pinning
|
||||
|
||||
Background setup guide:
|
||||
|
||||
- [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md)
|
||||
|
||||
### I Can See Browser Traffic but Not Node or Main-Process Traffic
|
||||
|
||||
Common causes:
|
||||
|
||||
- Node is not using system proxy settings
|
||||
- the HTTP client does not read `HTTP_PROXY`
|
||||
- only the system proxy was configured, but runtime environment variables were not
|
||||
|
||||
### I Cannot See `localhost` Traffic
|
||||
|
||||
Common cause:
|
||||
|
||||
- loopback traffic is bypassing the proxy
|
||||
|
||||
Keep this bypass list in mind when launching Electron:
|
||||
|
||||
```plaintext
|
||||
--proxy-bypass-list="<local>;localhost;127.0.0.1;::1"
|
||||
```
|
||||
|
||||
Adjust it only if your current debugging target requires a different loopback behavior.
|
||||
|
||||
### TLS Still Fails After the Certificate Was Installed
|
||||
|
||||
Common causes:
|
||||
|
||||
- the process started before the certificate was installed and needs a full restart
|
||||
- `ca.cer` was imported but `ca.pem` was not passed to Node when required
|
||||
- a corporate proxy, VPN, or security product is changing network behavior
|
||||
- the target app uses SSL pinning
|
||||
|
||||
## Security Notes
|
||||
|
||||
Keep local debugging settings out of the repository and out of public issue threads.
|
||||
|
||||
- Do not commit your local proxy address, certificate paths, cookies, or OAuth codes.
|
||||
- Do not leave `NODE_TLS_REJECT_UNAUTHORIZED=0` enabled beyond temporary local debugging.
|
||||
- Do not post raw tokens, sessions, or authorization headers in public issues.
|
||||
- Redact logs before sharing them.
|
||||
|
||||
## Minimal Workflows
|
||||
|
||||
### Official Antigravity Client
|
||||
|
||||
1. Complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
2. Open Proxyman.
|
||||
3. Run:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1
|
||||
```
|
||||
|
||||
4. Trigger login, quota, or model-list traffic.
|
||||
|
||||
### Antigravity Manager in This Repository
|
||||
|
||||
1. Complete [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md).
|
||||
2. Open PowerShell.
|
||||
3. Set `HTTP_PROXY`, `HTTPS_PROXY`, and `NODE_EXTRA_CA_CERTS`.
|
||||
4. Run `npm start`.
|
||||
5. Filter Proxyman traffic by domains such as:
|
||||
- `googleapis.com`
|
||||
- `googleusercontent.com`
|
||||
- the upstream domain you are debugging
|
||||
|
||||
## Related Files
|
||||
|
||||
- Setup guide: [Proxyman Installation and Basic Setup on Windows](./proxyman-install.md)
|
||||
- Official-client launcher: [scripts/start-antigravity-with-proxyman.ps1](../scripts/start-antigravity-with-proxyman.ps1)
|
||||
@@ -0,0 +1,140 @@
|
||||
# Proxyman Installation and Basic Setup on Windows
|
||||
|
||||
This guide covers the Proxyman prerequisites for contributors:
|
||||
|
||||
1. Install Proxyman for Windows
|
||||
2. Trust the Proxyman certificate so HTTPS traffic can be decrypted
|
||||
3. Verify that Proxyman is working before you start debugging Antigravity traffic
|
||||
|
||||
If your goal is to debug the installed Antigravity client or this repository's development build, continue with [Proxyman Debugging Guide](./proxyman-debugging.md).
|
||||
|
||||
## Scope
|
||||
|
||||
This document is intentionally limited to the setup layer. It does not cover Antigravity-specific debugging flows.
|
||||
|
||||
Use it when you need to answer:
|
||||
|
||||
- How do I install Proxyman on Windows?
|
||||
- How do I trust the certificate?
|
||||
- Why can I only see CONNECT requests but not HTTPS bodies?
|
||||
- How do I verify that Proxyman itself is working?
|
||||
|
||||
## Install Proxyman
|
||||
|
||||
Official resources:
|
||||
|
||||
- Download: [https://proxyman.com/download](https://proxyman.com/download)
|
||||
- Windows product page: [https://proxyman.com/windows](https://proxyman.com/windows)
|
||||
|
||||
Recommended steps:
|
||||
|
||||
1. Install the Windows build from the official download page.
|
||||
2. Launch Proxyman.
|
||||
3. Confirm that Proxyman is listening on a local proxy port.
|
||||
4. This guide uses `9090` as the example port. Replace it with your local port if it differs.
|
||||
|
||||
## Install and Trust the Certificate
|
||||
|
||||
Without the Proxyman root certificate, you will usually only see CONNECT tunnels or TLS handshakes instead of decrypted HTTPS requests.
|
||||
|
||||
Official references:
|
||||
|
||||
- Windows certificate installation: [https://docs.proxyman.com/proxyman-windows/install-certificate](https://docs.proxyman.com/proxyman-windows/install-certificate)
|
||||
- SSL troubleshooting: [https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response](https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response)
|
||||
|
||||
### Recommended Path: Install Automatically
|
||||
|
||||
In Proxyman, run:
|
||||
|
||||
```plaintext
|
||||
Certificate -> Install Certificate on this Windows...
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
1. Choose `Install & Trust`
|
||||
2. Approve the administrator prompt
|
||||
|
||||
According to the official documentation, Proxyman uses Windows `certutil` to import the root certificate into the trusted store.
|
||||
|
||||
### Prepare a PEM File for Node and Electron
|
||||
|
||||
Some Node.js and Electron traffic needs more than system trust. You may also need to point the runtime at the PEM file:
|
||||
|
||||
```plaintext
|
||||
%APPDATA%\Proxyman\certificate\certs\ca.pem
|
||||
```
|
||||
|
||||
If you only see `ca.cer` and not `ca.pem`, confirm that Proxyman has generated the PEM variant before moving on.
|
||||
|
||||
## Verify That Proxyman Works
|
||||
|
||||
Do this before trying to debug any application-specific flow.
|
||||
|
||||
### Verify Browser Traffic
|
||||
|
||||
1. Open Proxyman.
|
||||
2. Visit any HTTPS page in a browser, for example:
|
||||
|
||||
```plaintext
|
||||
https://httpbin.org/get
|
||||
```
|
||||
|
||||
3. Confirm that the request and response appear in Proxyman.
|
||||
|
||||
### If You Cannot See Any Traffic
|
||||
|
||||
Check these first:
|
||||
|
||||
- Proxyman is actually listening on the expected port
|
||||
- Windows proxy settings are pointing to Proxyman
|
||||
- A VPN, endpoint security agent, or antivirus is not taking over proxy behavior
|
||||
- The certificate is trusted
|
||||
|
||||
If localhost traffic is missing, see:
|
||||
|
||||
- [https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server](https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server)
|
||||
|
||||
The official page includes macOS-specific examples, but the localhost bypass concept is still relevant on Windows.
|
||||
|
||||
## Basic Troubleshooting
|
||||
|
||||
### HTTPS Bodies Are Missing
|
||||
|
||||
Common causes:
|
||||
|
||||
- The Proxyman root certificate is not actually trusted
|
||||
- The target process was already running before the certificate was installed
|
||||
- The application uses SSL pinning
|
||||
|
||||
### Certificate Is Installed but TLS Still Fails
|
||||
|
||||
Common causes:
|
||||
|
||||
- You imported `ca.cer` but did not provide `ca.pem` to the runtime when needed
|
||||
- A corporate proxy, VPN, or security product is overriding the network path
|
||||
- The target application uses SSL pinning
|
||||
|
||||
### Localhost Traffic Is Missing
|
||||
|
||||
Common cause:
|
||||
|
||||
- Local loopback traffic is bypassing the proxy
|
||||
|
||||
Official reference:
|
||||
|
||||
- [https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server](https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server)
|
||||
|
||||
## Next Step
|
||||
|
||||
Once Proxyman is installed, trusted, and verified, continue with:
|
||||
|
||||
- [Proxyman Debugging Guide](./proxyman-debugging.md)
|
||||
|
||||
## References
|
||||
|
||||
- Proxyman download page: [https://proxyman.com/download](https://proxyman.com/download)
|
||||
- Proxyman Windows page: [https://proxyman.com/windows](https://proxyman.com/windows)
|
||||
- Proxyman Windows certificate guide: [https://docs.proxyman.com/proxyman-windows/install-certificate](https://docs.proxyman.com/proxyman-windows/install-certificate)
|
||||
- Proxyman localhost troubleshooting: [https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server](https://docs.proxyman.com/troubleshooting/couldnt-see-any-request-from-localhost-server)
|
||||
- Proxyman SSL troubleshooting: [https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response](https://docs.proxyman.com/troubleshooting/get-ssl-error-from-https-request-and-response)
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1774106199,
|
||||
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
description = "Antigravity Manager";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
}: let
|
||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
|
||||
version = "0.10.0";
|
||||
|
||||
sources = {
|
||||
"x86_64-linux" = {
|
||||
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_amd64.deb";
|
||||
sha256 = "5fc08b4a9308c71fc00e44b95d4fb3178182aea8821708fa51fdf2bf5dbbb856";
|
||||
};
|
||||
"aarch64-linux" = {
|
||||
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_arm64.deb";
|
||||
sha256 = "666c9659efa66bb6be6a8e7fb0c384ceb799a17245eab374bf9b3d7901c5c4cb";
|
||||
};
|
||||
};
|
||||
|
||||
mkPackage = pkgs: let
|
||||
system = pkgs.stdenv.hostPlatform.system;
|
||||
source = sources.${system} or (throw "Unsupported system: ${system}");
|
||||
in
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "antigravity-manager";
|
||||
inherit version;
|
||||
|
||||
src = pkgs.fetchurl {
|
||||
inherit (source) url sha256;
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
autoPatchelfHook
|
||||
makeWrapper
|
||||
zstd
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
alsa-lib
|
||||
at-spi2-atk
|
||||
atk
|
||||
cups
|
||||
dbus
|
||||
expat
|
||||
glib
|
||||
gtk3
|
||||
libsecret
|
||||
libdrm
|
||||
libxkbcommon
|
||||
mesa
|
||||
nspr
|
||||
nss
|
||||
pango
|
||||
systemd
|
||||
libx11
|
||||
libxcomposite
|
||||
libxdamage
|
||||
libxext
|
||||
libxfixes
|
||||
libxrandr
|
||||
libxcb
|
||||
];
|
||||
|
||||
unpackPhase = ''
|
||||
ar x $src
|
||||
tar xf data.tar.* --no-same-permissions
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/lib $out/share
|
||||
cp -r usr/lib/antigravity-manager $out/lib/
|
||||
cp -r usr/share/* $out/share/
|
||||
|
||||
ln -s $out/lib/antigravity-manager/antigravity-manager $out/bin/antigravity-manager
|
||||
'';
|
||||
|
||||
postFixup = ''
|
||||
# Wrap the executable so it finds Wayland/X11 and libsecret
|
||||
wrapProgram $out/bin/antigravity-manager \
|
||||
--add-flags "--enable-features=WaylandWindowDecorations" \
|
||||
--add-flags "--ozone-platform-hint=auto" \
|
||||
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [pkgs.libsecret]}
|
||||
|
||||
if [ -e $out/share/applications/antigravity-manager.desktop ]; then
|
||||
substituteInPlace $out/share/applications/antigravity-manager.desktop \
|
||||
--replace "/usr/lib/antigravity-manager/antigravity-manager" "antigravity-manager"
|
||||
fi
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Professional multi-account manager for Google Gemini & Claude AI";
|
||||
homepage = "https://github.com/Draculabo/AntigravityManager";
|
||||
license = licenses.cc-by-nc-sa-40;
|
||||
maintainers = [];
|
||||
platforms = ["x86_64-linux" "aarch64-linux"];
|
||||
mainProgram = "antigravity-manager";
|
||||
};
|
||||
};
|
||||
in {
|
||||
overlays.default = final: prev: {
|
||||
antigravity-manager = mkPackage final;
|
||||
};
|
||||
|
||||
packages = forAllSystems (system: let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
in {
|
||||
default = mkPackage pkgs;
|
||||
});
|
||||
};
|
||||
}
|
||||
Generated
+20
-43
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "antigravity-manager",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
@@ -10773,7 +10773,7 @@
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -10935,7 +10935,7 @@
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -15178,7 +15178,7 @@
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -21722,7 +21722,7 @@
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -26034,7 +26034,7 @@
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -26050,7 +26050,7 @@
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -26475,7 +26475,7 @@
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -28085,18 +28085,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/seroval": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
|
||||
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/seroval/-/seroval-1.5.0.tgz",
|
||||
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/seroval-plugins": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.4.0.tgz",
|
||||
"integrity": "sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ==",
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/seroval-plugins/-/seroval-plugins-1.5.0.tgz",
|
||||
"integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -28618,38 +28618,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js": {
|
||||
"version": "1.9.10",
|
||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
|
||||
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
|
||||
"version": "1.9.11",
|
||||
"resolved": "https://registry.npmmirror.com/solid-js/-/solid-js-1.9.11.tgz",
|
||||
"integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
"seroval-plugins": "~1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js/node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js/node_modules/seroval-plugins": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
|
||||
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"seroval": "^1.0"
|
||||
"seroval": "~1.5.0",
|
||||
"seroval-plugins": "~1.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
|
||||
+9
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "antigravity-manager",
|
||||
"productName": "Antigravity Manager",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"description": "Antigravity Manager - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -153,5 +153,12 @@
|
||||
"winston-daily-rotate-file": "^5.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@bitdisaster/exe-icon-extractor",
|
||||
"@nestjs/core",
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"protobufjs"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -245,7 +245,7 @@ const releaseConfig = {
|
||||
'@semantic-release/git',
|
||||
{
|
||||
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
||||
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
|
||||
message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}',
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Starts the installed Antigravity app with Proxyman proxy settings for local debugging.
|
||||
#
|
||||
# Usage:
|
||||
# powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1
|
||||
# powershell -ExecutionPolicy Bypass -File .\scripts\start-antigravity-with-proxyman.ps1 -Port 9090 -AllowInsecureTls
|
||||
#
|
||||
# Notes:
|
||||
# - This script is intended for local debugging only.
|
||||
# - It terminates existing Antigravity processes first so the new environment variables take effect.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$Port = 9090,
|
||||
[string]$ProxyHost = '127.0.0.1',
|
||||
[string]$AppPath = 'C:\Users\Administrator\AppData\Local\Programs\Antigravity\Antigravity.exe',
|
||||
[string]$CaPath = 'C:\Users\Administrator\AppData\Roaming\Proxyman\certificate\certs\ca.pem',
|
||||
[string]$NoProxy = 'localhost,127.0.0.1,::1',
|
||||
[switch]$AllowInsecureTls,
|
||||
[switch]$KeepExisting
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-ColorLine {
|
||||
param(
|
||||
[string]$Color,
|
||||
[string]$Message
|
||||
)
|
||||
|
||||
Write-Host $Message -ForegroundColor $Color
|
||||
}
|
||||
|
||||
function Info {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Cyan' -Message "[INFO] $Message"
|
||||
}
|
||||
|
||||
function Success {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Green' -Message "[OK] $Message"
|
||||
}
|
||||
|
||||
function Warn {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Yellow' -Message "[WARN] $Message"
|
||||
}
|
||||
|
||||
function Fail {
|
||||
param([string]$Message)
|
||||
|
||||
Write-ColorLine -Color 'Red' -Message "[ERROR] $Message"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$proxyUrl = "http://${ProxyHost}:${Port}"
|
||||
$proxyBypassList = '<local>;localhost;127.0.0.1;::1'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $AppPath)) {
|
||||
Fail "Antigravity executable not found: $AppPath"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $CaPath)) {
|
||||
Fail "Proxyman CA file not found: $CaPath"
|
||||
}
|
||||
|
||||
Info "Proxy URL: $proxyUrl"
|
||||
Info "Antigravity path: $AppPath"
|
||||
Info "CA path: $CaPath"
|
||||
|
||||
if (-not $KeepExisting) {
|
||||
$existingProcesses = Get-Process -Name 'Antigravity' -ErrorAction SilentlyContinue
|
||||
if ($existingProcesses) {
|
||||
Warn "Stopping existing Antigravity processes so the new proxy environment takes effect..."
|
||||
$existingProcesses | Stop-Process -Force
|
||||
Start-Sleep -Milliseconds 800
|
||||
} else {
|
||||
Info 'No running Antigravity process found.'
|
||||
}
|
||||
} else {
|
||||
Warn 'KeepExisting enabled; existing Antigravity processes were not terminated.'
|
||||
}
|
||||
|
||||
$env:HTTP_PROXY = $proxyUrl
|
||||
$env:HTTPS_PROXY = $proxyUrl
|
||||
$env:ALL_PROXY = $proxyUrl
|
||||
$env:NO_PROXY = $NoProxy
|
||||
$env:ELECTRON_PROXY_SERVER = $proxyUrl
|
||||
$env:ELECTRON_PROXY_BYPASS_LIST = $proxyBypassList
|
||||
$env:NODE_EXTRA_CA_CERTS = $CaPath
|
||||
|
||||
if ($AllowInsecureTls) {
|
||||
$env:NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
Warn 'NODE_TLS_REJECT_UNAUTHORIZED=0 is enabled for this launch. Use for debugging only.'
|
||||
} else {
|
||||
Remove-Item Env:NODE_TLS_REJECT_UNAUTHORIZED -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Info 'Starting Antigravity with Proxyman proxy settings...'
|
||||
$process = Start-Process -FilePath $AppPath -ArgumentList @(
|
||||
"--proxy-server=$proxyUrl",
|
||||
"--proxy-bypass-list=$proxyBypassList"
|
||||
) -WorkingDirectory (Split-Path -Parent $AppPath) -PassThru
|
||||
|
||||
Success "Antigravity started. PID: $($process.Id)"
|
||||
Write-Host ''
|
||||
Write-Host 'Environment used for this launch:' -ForegroundColor Gray
|
||||
Write-Host " HTTP_PROXY=$($env:HTTP_PROXY)" -ForegroundColor Gray
|
||||
Write-Host " HTTPS_PROXY=$($env:HTTPS_PROXY)" -ForegroundColor Gray
|
||||
Write-Host " NO_PROXY=$($env:NO_PROXY)" -ForegroundColor Gray
|
||||
Write-Host " NODE_EXTRA_CA_CERTS=$($env:NODE_EXTRA_CA_CERTS)" -ForegroundColor Gray
|
||||
if ($AllowInsecureTls) {
|
||||
Write-Host ' NODE_TLS_REJECT_UNAUTHORIZED=0' -ForegroundColor Gray
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host 'If Proxyman still does not show traffic, verify that no older Antigravity instance is still alive.' -ForegroundColor Gray
|
||||
+49
-3
@@ -1,7 +1,8 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import type { DeviceProfile } from '@/types/account';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
|
||||
export function addGoogleAccount(input: { authCode: string }) {
|
||||
export function addGoogleAccount(input: { authCode: string; oauthClientKey?: string }) {
|
||||
return ipc.client.cloud.addGoogleAccount(input);
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ export function deleteCloudAccount(input: { accountId: string }) {
|
||||
}
|
||||
|
||||
export function refreshAccountQuota(input: { accountId: string }) {
|
||||
console.log(`[Action] Calling refreshAccountQuota for: ${input.accountId}`);
|
||||
return ipc.client.cloud.refreshAccountQuota(input);
|
||||
}
|
||||
|
||||
@@ -37,8 +39,29 @@ export function syncLocalAccount() {
|
||||
return ipc.client.cloud.syncLocalAccount();
|
||||
}
|
||||
|
||||
export function startAuthFlow() {
|
||||
return ipc.client.cloud.startAuthFlow();
|
||||
export interface OAuthClientDescriptor {
|
||||
key: string;
|
||||
label: string;
|
||||
client_id: string;
|
||||
is_active: boolean;
|
||||
is_builtin: boolean;
|
||||
}
|
||||
|
||||
export function startAuthFlow(input?: { oauthClientKey?: string }) {
|
||||
return ipc.client.cloud.startAuthFlow(input);
|
||||
}
|
||||
|
||||
export function listOAuthClients() {
|
||||
return ipc.client.cloud.listOAuthClients() as Promise<OAuthClientDescriptor[]>;
|
||||
}
|
||||
|
||||
export async function getActiveOAuthClient() {
|
||||
const response = await ipc.client.cloud.getActiveOAuthClient();
|
||||
return response.client_key;
|
||||
}
|
||||
|
||||
export function setActiveOAuthClient(input: { clientKey: string }) {
|
||||
return ipc.client.cloud.setActiveOAuthClient(input);
|
||||
}
|
||||
|
||||
export function getSwitchStatus() {
|
||||
@@ -88,3 +111,26 @@ export function deleteCloudIdentityProfileRevision(input: {
|
||||
export function openCloudIdentityStorageFolder() {
|
||||
return ipc.client.cloud.openIdentityStorageFolder();
|
||||
}
|
||||
|
||||
export function setAccountProxy(input: { accountId: string; proxyUrl: string | null }) {
|
||||
if (input.proxyUrl && !isValidProxyUrl(input.proxyUrl)) {
|
||||
throw new Error('Invalid proxy URL format');
|
||||
}
|
||||
return ipc.client.cloud.setAccountProxy(input);
|
||||
}
|
||||
|
||||
export function exportCloudAccounts(input: { stripTokens?: boolean }) {
|
||||
return ipc.client.cloud.exportCloudAccounts(input);
|
||||
}
|
||||
|
||||
export function importCloudAccounts(input: {
|
||||
jsonContent: string;
|
||||
strategy?: 'merge' | 'overwrite' | 'skip-existing';
|
||||
}) {
|
||||
try {
|
||||
JSON.parse(input.jsonContent);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON content provided for import');
|
||||
}
|
||||
return ipc.client.cloud.importCloudAccounts(input);
|
||||
}
|
||||
@@ -11,11 +11,19 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { formatDistanceToNow, type Locale } from 'date-fns';
|
||||
import { enUS, zhCN, ru } from 'date-fns/locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const DEFAULT_GLARE_POSITION = { x: 50, y: 50 };
|
||||
const DEFAULT_CARD_TRANSFORM = 'perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)';
|
||||
const DATE_LOCALE_MAP: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
'zh-CN': zhCN,
|
||||
ru: ru,
|
||||
};
|
||||
|
||||
interface AccountCardProps {
|
||||
account: Account;
|
||||
isCurrent: boolean;
|
||||
@@ -35,8 +43,8 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [transform, setTransform] = useState('');
|
||||
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
||||
const [cardTransform, setCardTransform] = useState('');
|
||||
const [glarePosition, setGlarePosition] = useState(DEFAULT_GLARE_POSITION);
|
||||
|
||||
const initials = account.name
|
||||
? account.name
|
||||
@@ -47,38 +55,38 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
.slice(0, 2)
|
||||
: account.email[0].toUpperCase();
|
||||
|
||||
// 处理鼠标移动,计算 3D 旋转角度
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Handle pointer movement and calculate 3D rotation angles
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!cardRef.current) return;
|
||||
|
||||
const rect = cardRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// 计算鼠标相对于卡片中心的位置 (-1 到 1)
|
||||
const relativeX = (e.clientX - centerX) / (rect.width / 2);
|
||||
const relativeY = (e.clientY - centerY) / (rect.height / 2);
|
||||
// Calculate pointer position relative to the card center (-1 to 1)
|
||||
const relativeX = (event.clientX - centerX) / (rect.width / 2);
|
||||
const relativeY = (event.clientY - centerY) / (rect.height / 2);
|
||||
|
||||
// 最大旋转角度
|
||||
// Maximum rotation angle
|
||||
const maxRotation = 15;
|
||||
const rotateX = -relativeY * maxRotation; // 上下倾斜
|
||||
const rotateY = relativeX * maxRotation; // 左右倾斜
|
||||
const rotateX = -relativeY * maxRotation; // Vertical tilt
|
||||
const rotateY = relativeX * maxRotation; // Horizontal tilt
|
||||
|
||||
setTransform(
|
||||
setCardTransform(
|
||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`,
|
||||
);
|
||||
|
||||
// 更新光泽位置
|
||||
// Update glare position
|
||||
setGlarePosition({
|
||||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||
x: ((event.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((event.clientY - rect.top) / rect.height) * 100,
|
||||
});
|
||||
};
|
||||
|
||||
// 鼠标离开时重置
|
||||
// Reset when pointer leaves
|
||||
const handleMouseLeave = () => {
|
||||
setTransform('perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)');
|
||||
setGlarePosition({ x: 50, y: 50 });
|
||||
setCardTransform(DEFAULT_CARD_TRANSFORM);
|
||||
setGlarePosition(DEFAULT_GLARE_POSITION);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -87,18 +95,18 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{
|
||||
transform,
|
||||
transform: cardTransform,
|
||||
transformStyle: 'preserve-3d',
|
||||
transition: transform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out',
|
||||
transition: cardTransform ? 'transform 0.1s ease-out' : 'transform 0.5s ease-out',
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
{/* 光泽效果层 */}
|
||||
{/* Glare overlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${glarePosition.x}% ${glarePosition.y}%, rgba(255,255,255,0.15) 0%, transparent 60%)`,
|
||||
opacity: transform ? 0.6 : 0,
|
||||
opacity: cardTransform ? 0.6 : 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -137,7 +145,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
{t('account.lastUsed', {
|
||||
time: formatDistanceToNow(new Date(account.last_used), {
|
||||
addSuffix: true,
|
||||
locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS,
|
||||
locale: DATE_LOCALE_MAP[i18n.language] || enUS,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
|
||||
+590
-154
@@ -1,4 +1,4 @@
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { CloudAccount, CloudQuotaModelInfo } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -17,19 +17,128 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react';
|
||||
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useProviderGrouping } from '@/hooks/useProviderGrouping';
|
||||
import { ProviderGroup } from '@/components/ProviderGroup';
|
||||
import {
|
||||
clampQuotaPercentage,
|
||||
formatResetTimeLabel,
|
||||
formatResetTimeTitle,
|
||||
getQuotaStatus,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
import { useState } from 'react';
|
||||
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus';
|
||||
|
||||
interface ModelQuotaInfo {
|
||||
percentage: number;
|
||||
resetTime: string;
|
||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||
|
||||
const GEMINI_LEGACY_MODEL_PATTERN = /gemini-[12](\.|$|-)/i;
|
||||
const GEMINI_PRO_COMBINED_MODEL_ID = 'gemini-3.1-pro-low/high';
|
||||
|
||||
const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [
|
||||
[GEMINI_PRO_COMBINED_MODEL_ID, 'Gemini 3.1 Pro (Low/High)'],
|
||||
['gemini-3.1-pro-preview', 'Gemini 3.1 Pro Preview'],
|
||||
['gemini-3-pro-image', 'Gemini 3 Pro Image'],
|
||||
['gemini-3.1-pro', 'Gemini 3.1 Pro'],
|
||||
['gemini-3-pro', 'Gemini 3 Pro'],
|
||||
['gemini-3-flash', 'Gemini 3 Flash'],
|
||||
['claude-sonnet-4-6-thinking', 'Claude 4.6 Sonnet (Thinking)'],
|
||||
['claude-sonnet-4-6', 'Claude 4.6 Sonnet'],
|
||||
['claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)'],
|
||||
['claude-sonnet-4-5', 'Claude 4.5 Sonnet'],
|
||||
['claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)'],
|
||||
['claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)'],
|
||||
['claude-3-5-sonnet', 'Claude 3.5 Sonnet'],
|
||||
];
|
||||
|
||||
const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'text-green-500',
|
||||
medium: 'text-yellow-500',
|
||||
low: 'text-red-500',
|
||||
};
|
||||
|
||||
const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'bg-emerald-500',
|
||||
medium: 'bg-amber-500',
|
||||
low: 'bg-rose-500',
|
||||
};
|
||||
|
||||
function isGeminiProLowModel(modelName: string): boolean {
|
||||
const normalizedModelName = modelName.toLowerCase();
|
||||
return normalizedModelName.includes('gemini-3.1-pro-low');
|
||||
}
|
||||
|
||||
function isGeminiProHighModel(modelName: string): boolean {
|
||||
const normalizedModelName = modelName.toLowerCase();
|
||||
return normalizedModelName.includes('gemini-3.1-pro-high');
|
||||
}
|
||||
|
||||
function isGeminiProFamilyModel(modelName: string): boolean {
|
||||
const normalizedModelName = modelName.toLowerCase();
|
||||
return normalizedModelName.includes('gemini-3.1-pro');
|
||||
}
|
||||
|
||||
function mergeGeminiProQuotaEntries(
|
||||
entries: ModelQuotaEntry[],
|
||||
): Record<string, CloudQuotaModelInfo> {
|
||||
const mergedModels: Record<string, CloudQuotaModelInfo> = {};
|
||||
const hasProLowModel = entries.some(([modelName]) => isGeminiProLowModel(modelName));
|
||||
const hasProHighModel = entries.some(([modelName]) => isGeminiProHighModel(modelName));
|
||||
const proLowModelInfo = entries.find(([modelName]) => isGeminiProLowModel(modelName))?.[1];
|
||||
|
||||
for (const [modelName, modelInfo] of entries) {
|
||||
if (isGeminiProLowModel(modelName) && hasProHighModel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isGeminiProHighModel(modelName) && hasProLowModel) {
|
||||
const mergedPercentage = proLowModelInfo
|
||||
? Math.min(modelInfo.percentage, proLowModelInfo.percentage)
|
||||
: modelInfo.percentage;
|
||||
|
||||
mergedModels[GEMINI_PRO_COMBINED_MODEL_ID] = {
|
||||
...modelInfo,
|
||||
...proLowModelInfo,
|
||||
percentage: mergedPercentage,
|
||||
display_name: 'Gemini 3.1 Pro',
|
||||
resetTime:
|
||||
modelInfo.resetTime && proLowModelInfo?.resetTime
|
||||
? modelInfo.resetTime < proLowModelInfo.resetTime
|
||||
? modelInfo.resetTime
|
||||
: proLowModelInfo.resetTime
|
||||
: modelInfo.resetTime || proLowModelInfo?.resetTime || '',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
mergedModels[modelName] = modelInfo;
|
||||
}
|
||||
|
||||
return mergedModels;
|
||||
}
|
||||
|
||||
function formatModelDisplayName(modelName: string): string {
|
||||
let displayName = modelName.replace('models/', '');
|
||||
for (const [source, target] of MODEL_DISPLAY_REPLACEMENTS) {
|
||||
displayName = displayName.replace(source, target);
|
||||
}
|
||||
|
||||
return displayName
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
interface CloudAccountCardProps {
|
||||
account: CloudAccount;
|
||||
onRefresh: (id: string) => void;
|
||||
@@ -56,122 +165,67 @@ export function CloudAccountCard({
|
||||
isSwitching,
|
||||
}: CloudAccountCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
const {
|
||||
enabled: providerGroupingsEnabled,
|
||||
getAccountStats,
|
||||
isProviderCollapsed,
|
||||
toggleProviderCollapse,
|
||||
} = useProviderGrouping();
|
||||
const setAccountProxy = useSetAccountProxy();
|
||||
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||
const [proxySaved, setProxySaved] = useState(false);
|
||||
|
||||
// Helpers to get quota color
|
||||
const getQuotaColor = (percentage: number) => {
|
||||
if (percentage > 80) return 'text-green-500';
|
||||
if (percentage > 20) return 'text-yellow-500';
|
||||
return 'text-red-500';
|
||||
const getQuotaTextColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
return QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||
};
|
||||
|
||||
const getQuotaBarColor = (percentage: number) => {
|
||||
if (percentage > 80) return 'bg-emerald-500';
|
||||
if (percentage > 20) return 'bg-amber-500';
|
||||
return 'bg-rose-500';
|
||||
const getQuotaBarColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||
};
|
||||
|
||||
const getQuotaLabel = (percentage: number) => {
|
||||
const formatQuotaLabel = (percentage: number) => {
|
||||
if (percentage === 0) {
|
||||
return t('cloud.card.rateLimitedQuota');
|
||||
}
|
||||
return `${percentage}%`;
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (dateStr: string) => {
|
||||
const targetDate = new Date(dateStr);
|
||||
if (Number.isNaN(targetDate.getTime())) return null;
|
||||
|
||||
const now = new Date();
|
||||
if (isBefore(targetDate, now)) return '0h 0m';
|
||||
|
||||
const diffHrs = Math.max(0, differenceInHours(targetDate, now));
|
||||
const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60);
|
||||
if (diffHrs >= 24) {
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
const remainingHrs = diffHrs % 24;
|
||||
return `${diffDays}d ${remainingHrs}h`;
|
||||
}
|
||||
return `${diffHrs}h ${diffMins}m`;
|
||||
const formatResetTimeLabelText = (resetTime?: string) => {
|
||||
return formatResetTimeLabel(resetTime, {
|
||||
prefix: t('cloud.card.resetPrefix'),
|
||||
unknown: t('cloud.card.resetUnknown'),
|
||||
});
|
||||
};
|
||||
|
||||
const getResetTimeLabel = (resetTime?: string) => {
|
||||
if (!resetTime) return t('cloud.card.resetUnknown');
|
||||
const remaining = formatTimeRemaining(resetTime);
|
||||
if (!remaining) return t('cloud.card.resetUnknown');
|
||||
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
|
||||
const formatResetTimeTitleText = (resetTime?: string) => {
|
||||
return formatResetTimeTitle(resetTime, t('cloud.card.resetTime'));
|
||||
};
|
||||
|
||||
const getResetTimeTitle = (resetTime?: string) => {
|
||||
if (!resetTime) return undefined;
|
||||
const resetDate = new Date(resetTime);
|
||||
if (Number.isNaN(resetDate.getTime())) return undefined;
|
||||
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
|
||||
};
|
||||
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||
|
||||
// --- Logic for model groups ---
|
||||
const rawModels = Object.entries(account.quota?.models || {}).filter(
|
||||
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||
);
|
||||
) as ModelQuotaEntry[];
|
||||
|
||||
// Group Gemini 3 Pro Low/High if both exist
|
||||
const processedModels: Record<string, ModelQuotaInfo> = {};
|
||||
const hasLow = rawModels.some(([name]) => name.includes('gemini-3-pro-low'));
|
||||
const hasHigh = rawModels.some(([name]) => name.includes('gemini-3-pro-high'));
|
||||
const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
|
||||
|
||||
for (const [name, info] of rawModels) {
|
||||
if (name.includes('gemini-3-pro-low') && hasHigh) continue;
|
||||
if (name.includes('gemini-3-pro-high') && hasLow) {
|
||||
// Use the lower percentage if both exist, to be safe
|
||||
const lowInfo = rawModels.find(([n]) => n.includes('gemini-3-pro-low'))?.[1];
|
||||
const mergedPercentage = lowInfo
|
||||
? Math.min(info.percentage, lowInfo.percentage)
|
||||
: info.percentage;
|
||||
processedModels['gemini-3-pro-low/high'] = { ...info, percentage: mergedPercentage };
|
||||
continue;
|
||||
}
|
||||
processedModels[name] = info;
|
||||
}
|
||||
|
||||
const geminiModels = Object.entries(processedModels)
|
||||
.filter(([name]) => name.includes('gemini') && !/gemini-[12](\.|$|-)/i.test(name))
|
||||
const geminiModels = Object.entries(mergedModelQuotas)
|
||||
.filter(([name]) => name.includes('gemini') && !GEMINI_LEGACY_MODEL_PATTERN.test(name))
|
||||
.sort((a, b) => b[1].percentage - a[1].percentage);
|
||||
|
||||
const claudeModels = Object.entries(processedModels)
|
||||
const claudeModels = Object.entries(mergedModelQuotas)
|
||||
.filter(([name]) => name.includes('claude'))
|
||||
.sort((a, b) => b[1].percentage - a[1].percentage);
|
||||
|
||||
const hasHighTier = geminiModels.some(
|
||||
([name, info]) => name.includes('gemini-3-pro') && info.percentage > 50,
|
||||
([name, info]) => isGeminiProFamilyModel(name) && info.percentage > 50,
|
||||
);
|
||||
const hasRenderableModels = geminiModels.length > 0 || claudeModels.length > 0;
|
||||
const hasVisibleQuotaModels = geminiModels.length > 0 || claudeModels.length > 0;
|
||||
|
||||
const formatModelName = (name: string) => {
|
||||
return name
|
||||
.replace('models/', '')
|
||||
.replace('gemini-3-pro-low/high', 'Gemini 3 Pro (Low/High)')
|
||||
.replace('gemini-3-pro-preview', 'Gemini 3 Pro Preview')
|
||||
.replace('gemini-3-pro-image', 'Gemini 3 Pro Image')
|
||||
.replace('gemini-3-pro', 'Gemini 3 Pro')
|
||||
.replace('gemini-3-flash', 'Gemini 3 Flash')
|
||||
.replace('claude-sonnet-4-5-thinking', 'Claude 4.5 Sonnet (Thinking)')
|
||||
.replace('claude-sonnet-4-5', 'Claude 4.5 Sonnet')
|
||||
.replace('claude-opus-4-6-thinking', 'Claude 4.6 Opus (Thinking)')
|
||||
.replace('claude-opus-4-5-thinking', 'Claude 4.5 Opus (Thinking)')
|
||||
.replace('claude-3-5-sonnet', 'Claude 3.5 Sonnet')
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => (word.length > 2 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const renderModelGroup = (title: string, models: [string, ModelQuotaInfo][]) => {
|
||||
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
|
||||
if (models.length === 0) return null;
|
||||
return (
|
||||
<div key={title} className="space-y-1">
|
||||
@@ -190,25 +244,25 @@ export function CloudAccountCard({
|
||||
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
||||
title={modelName}
|
||||
>
|
||||
{formatModelName(modelName)}
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className="text-muted-foreground text-[9px] leading-none opacity-80"
|
||||
title={getResetTimeTitle(info.resetTime)}
|
||||
title={formatResetTimeTitleText(info.resetTime)}
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
{formatResetTimeLabelText(info.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={`font-mono text-xs leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||
className={`font-mono text-xs leading-none font-bold ${getQuotaTextColorClass(info.percentage)}`}
|
||||
>
|
||||
{info.percentage}%
|
||||
</span>
|
||||
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,6 +273,76 @@ export function CloudAccountCard({
|
||||
);
|
||||
};
|
||||
|
||||
const emptyQuotaState = (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
|
||||
const providerGroupedQuotaSection =
|
||||
providerStats && providerStats.visibleModels > 0 ? (
|
||||
<>
|
||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
||||
>
|
||||
{formatQuotaLabel(providerStats.overallPercentage)}
|
||||
</span>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
|
||||
style={{
|
||||
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{providerStats.providers.map((group) => (
|
||||
<ProviderGroup
|
||||
key={group.providerKey}
|
||||
stats={group}
|
||||
isCollapsed={isProviderCollapsed(account.id, group.providerKey)}
|
||||
onToggleCollapse={() => toggleProviderCollapse(account.id, group.providerKey)}
|
||||
getQuotaTextColorClass={getQuotaTextColorClass}
|
||||
getQuotaBarColorClass={getQuotaBarColorClass}
|
||||
formatQuotaLabel={formatQuotaLabel}
|
||||
formatResetTimeLabel={formatResetTimeLabelText}
|
||||
formatResetTimeTitle={formatResetTimeTitleText}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
emptyQuotaState
|
||||
);
|
||||
|
||||
const aiCredits = account.quota?.ai_credits;
|
||||
const shouldShowAiCredits = !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const formatCreditsExpiry = (expiryDate: string) => {
|
||||
if (!expiryDate) return '';
|
||||
try {
|
||||
const date = new Date(expiryDate);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return expiryDate;
|
||||
}
|
||||
};
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
account.status_reason,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||
@@ -252,7 +376,71 @@ export function CloudAccountCard({
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-amber-500">
|
||||
<span>${aiCredits.credits.toFixed(2)}</span>
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground opacity-70">
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
);
|
||||
})()}
|
||||
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
@@ -293,7 +481,11 @@ export function CloudAccountCard({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
||||
variant={
|
||||
account.status === 'rate_limited' || account.status === 'expired'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
@@ -303,9 +495,9 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'rate_limited' && (
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive text-xs font-medium">
|
||||
{t('cloud.card.rateLimited')}
|
||||
{validationBlockedStatusLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -344,66 +536,15 @@ export function CloudAccountCard({
|
||||
|
||||
<div className="space-y-2">
|
||||
{providerGroupingsEnabled ? (
|
||||
(() => {
|
||||
const accountStats = getAccountStats(account);
|
||||
if (accountStats.visibleModels === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono font-bold ${getQuotaColor(accountStats.overallPercentage)}`}
|
||||
>
|
||||
{getQuotaLabel(accountStats.overallPercentage)}
|
||||
</span>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(accountStats.overallPercentage)}`}
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, accountStats.overallPercentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{accountStats.providers.map((providerStats) => (
|
||||
<ProviderGroup
|
||||
key={providerStats.providerKey}
|
||||
stats={providerStats}
|
||||
isCollapsed={isProviderCollapsed(account.id, providerStats.providerKey)}
|
||||
onToggleCollapse={() =>
|
||||
toggleProviderCollapse(account.id, providerStats.providerKey)
|
||||
}
|
||||
getQuotaColor={getQuotaColor}
|
||||
getQuotaBarColor={getQuotaBarColor}
|
||||
getQuotaLabel={getQuotaLabel}
|
||||
getResetTimeLabel={getResetTimeLabel}
|
||||
getResetTimeTitle={getResetTimeTitle}
|
||||
leftLabel={t('cloud.card.left')}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : hasRenderableModels ? (
|
||||
providerGroupedQuotaSection
|
||||
) : hasVisibleQuotaModels ? (
|
||||
<div className="space-y-3">
|
||||
{renderModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
||||
{renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
||||
<div className="pt-1" />
|
||||
{renderModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
||||
{renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
emptyQuotaState
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -413,7 +554,302 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={proxyUrl}
|
||||
onChange={(e) => {
|
||||
setProxyUrl(e.target.value);
|
||||
setProxySaved(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = proxyUrl.trim();
|
||||
if (trimmed && !isValidProxyUrl(trimmed)) {
|
||||
setProxyUrl(account.proxy_url || '');
|
||||
return;
|
||||
}
|
||||
if (trimmed !== (account.proxy_url || '')) {
|
||||
setAccountProxy.mutate({
|
||||
accountId: account.id,
|
||||
proxyUrl: trimmed || null,
|
||||
});
|
||||
setProxySaved(true);
|
||||
setTimeout(() => setProxySaved(false), 2000);
|
||||
}
|
||||
}}
|
||||
placeholder={t('cloud.card.proxyPlaceholder')}
|
||||
className="h-6 w-40 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{proxySaved && (
|
||||
<span className="text-[10px] text-green-500">{t('cloud.card.proxySaved')}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompactCloudAccountCardProps {
|
||||
account: CloudAccount;
|
||||
onRefresh: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSwitch: (id: string) => void;
|
||||
onManageIdentity: (id: string) => void;
|
||||
isRefreshing?: boolean;
|
||||
isDeleting?: boolean;
|
||||
isSwitching?: boolean;
|
||||
}
|
||||
|
||||
export function CompactCloudAccountCard({
|
||||
account,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
onSwitch,
|
||||
onManageIdentity,
|
||||
isRefreshing,
|
||||
isDeleting,
|
||||
isSwitching,
|
||||
}: CompactCloudAccountCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
|
||||
const getQuotaBarColorClass = (percentage: number) => {
|
||||
const quotaStatus = getQuotaStatus(percentage);
|
||||
return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||
};
|
||||
|
||||
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||
) as ModelQuotaEntry[];
|
||||
|
||||
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||
|
||||
const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
|
||||
|
||||
const compactModels = Object.entries(mergedModelQuotas).sort(
|
||||
(a, b) => b[1].percentage - a[1].percentage,
|
||||
);
|
||||
|
||||
const aiCredits = account.quota?.ai_credits;
|
||||
const shouldShowAiCredits = !!aiCredits && Number.isFinite(aiCredits.credits) && aiCredits.credits >= 0;
|
||||
|
||||
const formatCreditsExpiry = (expiryDate: string) => {
|
||||
if (!expiryDate) return '';
|
||||
try {
|
||||
const date = new Date(expiryDate);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return expiryDate;
|
||||
}
|
||||
};
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
account.status_reason,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-7 w-7 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
account.status === 'rate_limited' || account.status === 'expired'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
</Badge>
|
||||
{account.is_active && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600"
|
||||
>
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<span className="truncate">{account.email}</span>
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive shrink-0 font-medium">{validationBlockedStatusLabel}</span>
|
||||
)}
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<span className="shrink-0 text-amber-500">
|
||||
${aiCredits.credits.toFixed(2)}
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{compactModels.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
{compactModels.map(([modelName, info]) => (
|
||||
<TooltipProvider key={modelName}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{formatModelDisplayName(modelName)}: {info.percentage}%
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
);
|
||||
})()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{account.is_active ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
className="h-7 text-xs text-green-600 opacity-100"
|
||||
>
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="h-7 cursor-pointer text-xs"
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{t('cloud.card.use')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.useAccount')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.refresh')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.identityProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+616
-139
@@ -8,17 +8,26 @@ import {
|
||||
useSetAutoSwitchEnabled,
|
||||
useForcePollCloudMonitor,
|
||||
useSyncLocalAccount,
|
||||
useOAuthClients,
|
||||
useSetActiveOAuthClient,
|
||||
startAuthFlow,
|
||||
useExportCloudAccounts,
|
||||
useImportCloudAccounts,
|
||||
} from '@/hooks/useCloudAccounts';
|
||||
import { CloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { IdentityProfileDialog } from '@/components/IdentityProfileDialog';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
Cloud,
|
||||
Zap,
|
||||
RefreshCcw,
|
||||
@@ -31,6 +40,12 @@ import {
|
||||
List,
|
||||
Columns2,
|
||||
Columns3,
|
||||
SortAsc,
|
||||
LayoutList,
|
||||
Upload,
|
||||
FileDown,
|
||||
Check,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
@@ -44,21 +59,48 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es';
|
||||
import {
|
||||
clampQuotaPercentage,
|
||||
getQuotaStatus,
|
||||
roundQuotaPercentage,
|
||||
getAccountSortValue,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
import { shouldAutoSubmitGoogleAuthCode } from '@/utils/googleAuthSubmission';
|
||||
|
||||
// ... (existing code: imports and comments)
|
||||
|
||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list';
|
||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
|
||||
|
||||
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
||||
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
||||
'2-col': 'grid gap-4 grid-cols-2',
|
||||
'3-col': 'grid gap-4 grid-cols-3',
|
||||
list: 'grid gap-4 grid-cols-1',
|
||||
compact: 'flex flex-col gap-2',
|
||||
};
|
||||
|
||||
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'bg-emerald-500',
|
||||
medium: 'bg-amber-500',
|
||||
low: 'bg-rose-500',
|
||||
};
|
||||
|
||||
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||
high: 'text-emerald-600 dark:text-emerald-400',
|
||||
medium: 'text-amber-600 dark:text-amber-400',
|
||||
low: 'text-rose-600 dark:text-rose-400',
|
||||
};
|
||||
|
||||
export function CloudAccountList() {
|
||||
@@ -74,106 +116,160 @@ export function CloudAccountList() {
|
||||
const { data: autoSwitchEnabled, isLoading: isSettingsLoading } = useAutoSwitchEnabled();
|
||||
const setAutoSwitchMutation = useSetAutoSwitchEnabled();
|
||||
const forcePollMutation = useForcePollCloudMonitor();
|
||||
const { data: oauthClients = [], isLoading: isOAuthClientsLoading } = useOAuthClients();
|
||||
const setActiveOAuthClientMutation = useSetActiveOAuthClient();
|
||||
|
||||
const { toast } = useToast();
|
||||
const lastCloudLoadErrorToastAt = useRef<number>(0);
|
||||
const lastLoadErrorToastAtRef = useRef<number>(0);
|
||||
const lastSubmittedAuthCodeRef = useRef<string | null>(null);
|
||||
|
||||
const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto';
|
||||
|
||||
const setGridLayout = async (layout: GridLayout) => {
|
||||
const updateGridLayout = async (layout: GridLayout) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, grid_layout: layout });
|
||||
}
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
'recently-used',
|
||||
'quota-overall',
|
||||
'quota-claude',
|
||||
'quota-pro3',
|
||||
'quota-flash',
|
||||
] as const;
|
||||
type SortOption = (typeof sortOptions)[number];
|
||||
const currentSort: SortOption = (config?.account_sort as SortOption) || 'recently-used';
|
||||
const sortI18nKeys: Record<SortOption, string> = {
|
||||
'recently-used': 'cloud.sort.recentlyUsed',
|
||||
'quota-overall': 'cloud.sort.quotaOverall',
|
||||
'quota-claude': 'cloud.sort.quotaClaude',
|
||||
'quota-pro3': 'cloud.sort.quotaPro3',
|
||||
'quota-flash': 'cloud.sort.quotaFlash',
|
||||
};
|
||||
|
||||
const sortedAccounts = useMemo(() => {
|
||||
if (!accounts || accounts.length === 0) return [];
|
||||
|
||||
const activeAccounts = accounts.filter((a) => a.is_active);
|
||||
const otherAccounts = accounts.filter((a) => !a.is_active);
|
||||
|
||||
const sortedActive = [...activeAccounts].sort((a, b) => {
|
||||
return (b.last_used ?? 0) - (a.last_used ?? 0);
|
||||
});
|
||||
|
||||
const sortedOthers = [...otherAccounts].sort((a, b) => {
|
||||
if (currentSort === 'recently-used') {
|
||||
return (b.last_used ?? 0) - (a.last_used ?? 0);
|
||||
}
|
||||
return getAccountSortValue(b, currentSort) - getAccountSortValue(a, currentSort);
|
||||
});
|
||||
|
||||
return [...sortedActive, ...sortedOthers];
|
||||
}, [accounts, currentSort]);
|
||||
|
||||
// Calculate global quota across all accounts
|
||||
const globalQuota = useMemo(() => {
|
||||
const overallQuotaPercentage = useMemo(() => {
|
||||
if (!accounts || accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibilitySettings = config?.model_visibility ?? {};
|
||||
let totalPercentage = 0;
|
||||
let modelCount = 0;
|
||||
|
||||
accounts.forEach((account) => {
|
||||
const visibleModelInfos = flatMap(accounts, (account) => {
|
||||
if (!account.quota?.models) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
Object.entries(account.quota.models).forEach(([modelName, info]) => {
|
||||
if (visibilitySettings[modelName] !== false) {
|
||||
totalPercentage += info.percentage;
|
||||
modelCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(account.quota.models)
|
||||
.filter(([modelName]) => visibilitySettings[modelName] !== false)
|
||||
.map(([, info]) => info);
|
||||
});
|
||||
|
||||
if (modelCount === 0) {
|
||||
if (isEmpty(visibleModelInfos)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round((totalPercentage / modelCount) * 10) / 10;
|
||||
const averagePercentage =
|
||||
sumBy(visibleModelInfos, (modelInfo) => modelInfo.percentage) / visibleModelInfos.length;
|
||||
|
||||
return roundQuotaPercentage(averagePercentage);
|
||||
}, [accounts, config?.model_visibility]);
|
||||
|
||||
const getGlobalQuotaColor = (percentage: number) => {
|
||||
if (percentage > 80) {
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
if (percentage > 20) {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
return 'bg-rose-500';
|
||||
};
|
||||
|
||||
const getGlobalQuotaTextColor = (percentage: number) => {
|
||||
if (percentage > 80) {
|
||||
return 'text-emerald-600 dark:text-emerald-400';
|
||||
}
|
||||
if (percentage > 20) {
|
||||
return 'text-amber-600 dark:text-amber-400';
|
||||
}
|
||||
return 'text-rose-600 dark:text-rose-400';
|
||||
};
|
||||
const overallQuotaStatus =
|
||||
overallQuotaPercentage === null ? null : getQuotaStatus(overallQuotaPercentage);
|
||||
const effectiveQuotaStatus: QuotaStatus = overallQuotaStatus ?? 'low';
|
||||
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
const [selectedOAuthClientKey, setSelectedOAuthClientKey] = useState('');
|
||||
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
|
||||
const totalAccounts = accounts?.length || 0;
|
||||
const activeAccounts = accounts?.filter((account) => account.is_active).length || 0;
|
||||
const rateLimitedAccounts =
|
||||
accounts?.filter((account) => account.status === 'rate_limited').length || 0;
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [importStrategy, setImportStrategy] = useState<'merge' | 'overwrite' | 'skip-existing'>(
|
||||
'merge',
|
||||
);
|
||||
const [importFileContent, setImportFileContent] = useState<string | null>(null);
|
||||
const [importFileName, setImportFileName] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const exportMutation = useExportCloudAccounts();
|
||||
const importMutation = useImportCloudAccounts();
|
||||
const totalAccounts = size(accounts);
|
||||
const activeAccounts = filter(accounts, (account) => account.is_active).length;
|
||||
const rateLimitedAccounts = filter(
|
||||
accounts,
|
||||
(account) => account.status === 'rate_limited',
|
||||
).length;
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
if (!codeToUse) {
|
||||
const submitAuthCode = useCallback(
|
||||
(incomingAuthCode?: string) => {
|
||||
const codeToUse = incomingAuthCode || authCode;
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
lastSubmittedAuthCodeRef.current = codeToUse;
|
||||
addMutation.mutate(
|
||||
{
|
||||
authCode: codeToUse,
|
||||
oauthClientKey:
|
||||
selectedOAuthClientKey || oauthClients.find((client) => client.is_active)?.key,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[addMutation, authCode, oauthClients, selectedOAuthClientKey, t, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOAuthClientKey !== '') {
|
||||
return;
|
||||
}
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.toast.addFailed.title'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
const activeClientKey = oauthClients.find((client) => client.is_active)?.key;
|
||||
if (activeClientKey) {
|
||||
setSelectedOAuthClientKey(activeClientKey);
|
||||
}
|
||||
}, [oauthClients, selectedOAuthClientKey]);
|
||||
// Listen for Google Auth Code
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
console.log('[OAuth] Setting up auth code listener, dialog open:', isAddDialogOpen);
|
||||
console.log('[OAuth] Registering Google auth code IPC listener');
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
console.log('[OAuth] Received auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
setAuthCode(code);
|
||||
// Note: Auto-submit will be triggered by the authCode change effect below
|
||||
});
|
||||
return cleanup;
|
||||
}
|
||||
@@ -181,17 +277,24 @@ export function CloudAccountList() {
|
||||
|
||||
// Auto-submit when authCode is set and dialog is open
|
||||
useEffect(() => {
|
||||
if (authCode && isAddDialogOpen && !addMutation.isPending) {
|
||||
console.log('[OAuth] Auto-submitting auth code');
|
||||
handleAddAccount(authCode);
|
||||
if (
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode,
|
||||
isAddDialogOpen,
|
||||
isPending: addMutation.isPending,
|
||||
lastSubmittedAuthCode: lastSubmittedAuthCodeRef.current,
|
||||
})
|
||||
) {
|
||||
console.log('[OAuth] Auto-submitting Google auth code');
|
||||
submitAuthCode(authCode);
|
||||
}
|
||||
}, [authCode, isAddDialogOpen]);
|
||||
}, [addMutation.isPending, authCode, isAddDialogOpen, submitAuthCode]);
|
||||
|
||||
// Batch Operations State
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!isError || !errorUpdatedAt || errorUpdatedAt === lastCloudLoadErrorToastAt.current) {
|
||||
if (!isError || !errorUpdatedAt || errorUpdatedAt === lastLoadErrorToastAtRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -200,16 +303,29 @@ export function CloudAccountList() {
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
lastCloudLoadErrorToastAt.current = errorUpdatedAt;
|
||||
lastLoadErrorToastAtRef.current = errorUpdatedAt;
|
||||
}, [error, errorUpdatedAt, isError, t, toast]);
|
||||
|
||||
// ... (existing code: handleRefresh, handleSwitch, handleDelete)
|
||||
|
||||
const handleRefresh = (id: string) => {
|
||||
console.log(`[Renderer] Triggering refresh for: ${id}`);
|
||||
refreshMutation.mutate(
|
||||
{ accountId: id },
|
||||
{
|
||||
onSuccess: () => toast({ title: t('cloud.toast.quotaRefreshed') }),
|
||||
onSuccess: (updatedAccount) => {
|
||||
const credits = updatedAccount.quota?.ai_credits?.credits;
|
||||
if (typeof credits === 'number') {
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `AI credits: $${credits.toFixed(2)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: 'AI credits not available for this refresh.',
|
||||
});
|
||||
},
|
||||
onError: () => toast({ title: t('cloud.toast.refreshFailed'), variant: 'destructive' }),
|
||||
},
|
||||
);
|
||||
@@ -312,20 +428,135 @@ export function CloudAccountList() {
|
||||
});
|
||||
};
|
||||
|
||||
const openAuthUrl = async () => {
|
||||
const openGoogleAuthSignIn = async () => {
|
||||
try {
|
||||
await startAuthFlow();
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
const effectiveClientKey =
|
||||
selectedOAuthClientKey || oauthClients.find((client) => client.is_active)?.key;
|
||||
await startAuthFlow(
|
||||
effectiveClientKey
|
||||
? {
|
||||
oauthClientKey: effectiveClientKey,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t('cloud.toast.startAuthFailed'), // Need to add this key or just use generic error
|
||||
title: t('cloud.toast.startAuthFailed'),
|
||||
description: String(e),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (stripTokens: boolean) => {
|
||||
let url: string | null = null;
|
||||
try {
|
||||
const jsonContent: string = await exportMutation.mutateAsync({ stripTokens });
|
||||
const blob = new Blob([jsonContent], { type: 'application/json' });
|
||||
url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cloud-accounts-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setIsExportDialogOpen(false);
|
||||
toast({ title: t('cloud.exportImport.exportSuccess') });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'File size exceeds 5MB limit',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setImportFileName(file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
JSON.parse(content);
|
||||
setImportFileContent(content);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'Invalid JSON file format',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setImportFileName('');
|
||||
setImportFileContent(null);
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: 'Failed to read file',
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
if (e.target) {
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
if (!importFileContent) return;
|
||||
importMutation.mutate(
|
||||
{ jsonContent: importFileContent, strategy: importStrategy },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setIsImportDialogOpen(false);
|
||||
setImportFileContent(null);
|
||||
setImportFileName('');
|
||||
setImportStrategy('merge');
|
||||
toast({
|
||||
title: t('cloud.exportImport.importSuccess', {
|
||||
imported: result.imported,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
});
|
||||
if (result.errors.length > 0) {
|
||||
toast({
|
||||
title: t('cloud.exportImport.importErrors', { count: result.errors.length }),
|
||||
description: result.errors.slice(0, 3).join('\n'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({
|
||||
title: t('cloud.error.loadFailed'),
|
||||
description: getLocalizedErrorMessage(err, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Batch Selection Handlers
|
||||
const toggleSelection = (id: string, selected: boolean) => {
|
||||
const setSelectionState = (id: string, selected: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
@@ -337,7 +568,7 @@ export function CloudAccountList() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const toggleSelectAllAccounts = () => {
|
||||
if (selectedIds.size === accounts?.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
@@ -345,26 +576,54 @@ export function CloudAccountList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchRefresh = () => {
|
||||
selectedIds.forEach((id) => {
|
||||
refreshMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `triggered for ${selectedIds.size} accounts.`,
|
||||
});
|
||||
const refreshSelectedAccounts = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => refreshMutation.mutateAsync({ accountId: id })),
|
||||
);
|
||||
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t('cloud.toast.quotaRefreshed'),
|
||||
description: `Successfully refreshed ${successful} accounts.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Refresh completed with issues`,
|
||||
description: `Refreshed ${successful} accounts, ${failed} failed.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
const deleteSelectedAccounts = async () => {
|
||||
if (confirm(t('cloud.batch.confirmDelete', { count: selectedIds.size }))) {
|
||||
selectedIds.forEach((id) => {
|
||||
deleteMutation.mutate({ accountId: id });
|
||||
});
|
||||
toast({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `${selectedIds.size} accounts deleted.`,
|
||||
});
|
||||
const ids = Array.from(selectedIds);
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => deleteMutation.mutateAsync({ accountId: id })),
|
||||
);
|
||||
|
||||
const successful = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
|
||||
if (failed === 0) {
|
||||
toast({
|
||||
title: t('cloud.toast.deleted'),
|
||||
description: `Successfully deleted ${successful} accounts.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Deletion completed with issues`,
|
||||
description: `Deleted ${successful} accounts, ${failed} failed.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
@@ -427,21 +686,21 @@ export function CloudAccountList() {
|
||||
<div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div>
|
||||
</div>
|
||||
{/* Global Quota */}
|
||||
{globalQuota !== null && (
|
||||
{overallQuotaPercentage !== null && (
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">
|
||||
{t('cloud.globalQuota')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-base font-semibold ${getGlobalQuotaTextColor(globalQuota)}`}
|
||||
className={`text-base font-semibold ${GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS[effectiveQuotaStatus]}`}
|
||||
>
|
||||
{globalQuota}%
|
||||
{overallQuotaPercentage}%
|
||||
</span>
|
||||
<div className="bg-muted h-2 w-20 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getGlobalQuotaColor(globalQuota)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, globalQuota))}%` }}
|
||||
className={`h-full rounded-full transition-all duration-300 ${GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS[effectiveQuotaStatus]}`}
|
||||
style={{ width: `${clampQuotaPercentage(overallQuotaPercentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,7 +730,7 @@ export function CloudAccountList() {
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleSelectAll}
|
||||
onClick={toggleSelectAllAccounts}
|
||||
title={t('cloud.batch.selectAll')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
@@ -503,21 +762,176 @@ export function CloudAccountList() {
|
||||
{t('cloud.syncFromIDE')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<Dialog open={isExportDialogOpen} onOpenChange={setIsExportDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
{t('cloud.exportImport.export')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.exportImport.exportTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.exportImport.exportDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex w-full flex-col gap-2 sm:flex-row sm:justify-center sm:space-x-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleExport(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
className="w-full cursor-pointer sm:flex-1"
|
||||
>
|
||||
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.exportImport.includeTokens')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleExport(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
className="w-full cursor-pointer sm:flex-1"
|
||||
>
|
||||
{exportMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.exportImport.stripTokens')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isImportDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsImportDialogOpen(open);
|
||||
if (!open) {
|
||||
setImportFileContent(null);
|
||||
setImportFileName('');
|
||||
setImportStrategy('merge');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('cloud.exportImport.import')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.exportImport.importTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.exportImport.importDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('cloud.exportImport.selectFile')}</Label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImportFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{importFileName || t('cloud.exportImport.selectFile')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t('cloud.exportImport.importStrategy')}</Label>
|
||||
<Select
|
||||
value={importStrategy}
|
||||
onValueChange={(v: 'merge' | 'overwrite' | 'skip-existing') =>
|
||||
setImportStrategy(v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="merge">{t('cloud.exportImport.strategyMerge')}</SelectItem>
|
||||
<SelectItem value="overwrite">
|
||||
{t('cloud.exportImport.strategyOverwrite')}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip-existing">
|
||||
{t('cloud.exportImport.strategySkip')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!importFileContent || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{importMutation.isPending
|
||||
? t('cloud.exportImport.importing')
|
||||
: t('cloud.exportImport.import')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddDialogOpen(open);
|
||||
if (!open) {
|
||||
setAuthCode('');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="oauth-client-select">{t('cloud.authDialog.oauthClient')}</Label>
|
||||
<Select
|
||||
value={selectedOAuthClientKey || undefined}
|
||||
onValueChange={(value) => {
|
||||
setSelectedOAuthClientKey(value);
|
||||
setActiveOAuthClientMutation.mutate(
|
||||
{
|
||||
clientKey: value,
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('cloud.toast.updateSettingsFailed'),
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={isOAuthClientsLoading || setActiveOAuthClientMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="oauth-client-select">
|
||||
<SelectValue placeholder={t('cloud.authDialog.oauthClientPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{oauthClients.map((client) => (
|
||||
<SelectItem key={client.key} value={client.key}>
|
||||
{client.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Button variant="outline" className="col-span-4" onClick={openAuthUrl}>
|
||||
<Button variant="outline" className="col-span-4" onClick={openGoogleAuthSignIn}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('cloud.authDialog.openLogin')}
|
||||
</Button>
|
||||
@@ -528,14 +942,16 @@ export function CloudAccountList() {
|
||||
id="code"
|
||||
placeholder={t('cloud.authDialog.placeholder')}
|
||||
value={authCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthCode(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAuthCode(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t('cloud.authDialog.instruction')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handleAddAccount()}
|
||||
onClick={() => submitAuthCode()}
|
||||
disabled={addMutation.isPending || !authCode}
|
||||
>
|
||||
{addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
@@ -545,7 +961,35 @@ export function CloudAccountList() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Layout Selector */}
|
||||
{/* Sort + Layout Selector */}
|
||||
<div className="flex items-center gap-1 rounded-md border p-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer">
|
||||
<SortAsc className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56" side="bottom" sideOffset={8}>
|
||||
{sortOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option}
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, account_sort: option });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentSort === option && <Check className="mr-2 h-4 w-4" />}
|
||||
<span className={currentSort === option ? '' : 'ml-6'}>
|
||||
{t(sortI18nKeys[option])}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1 rounded-md border p-1">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
@@ -554,7 +998,7 @@ export function CloudAccountList() {
|
||||
variant={gridLayout === 'auto' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
onClick={() => setGridLayout('auto')}
|
||||
onClick={() => updateGridLayout('auto')}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -567,7 +1011,7 @@ export function CloudAccountList() {
|
||||
variant={gridLayout === '2-col' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
onClick={() => setGridLayout('2-col')}
|
||||
onClick={() => updateGridLayout('2-col')}
|
||||
>
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -580,7 +1024,7 @@ export function CloudAccountList() {
|
||||
variant={gridLayout === '3-col' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
onClick={() => setGridLayout('3-col')}
|
||||
onClick={() => updateGridLayout('3-col')}
|
||||
>
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -593,41 +1037,74 @@ export function CloudAccountList() {
|
||||
variant={gridLayout === 'list' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
onClick={() => setGridLayout('list')}
|
||||
onClick={() => updateGridLayout('list')}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={gridLayout === 'compact' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer"
|
||||
onClick={() => updateGridLayout('compact')}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('cloud.layout.compact')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={toggleSelection}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{sortedAccounts.map((account) =>
|
||||
gridLayout === 'compact' ? (
|
||||
<CompactCloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CloudAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={setSelectionState}
|
||||
isRefreshing={
|
||||
refreshMutation.isPending && refreshMutation.variables?.accountId === account.id
|
||||
}
|
||||
isDeleting={
|
||||
deleteMutation.isPending && deleteMutation.variables?.accountId === account.id
|
||||
}
|
||||
isSwitching={
|
||||
switchMutation.isPending && switchMutation.variables?.accountId === account.id
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{accounts?.length === 0 && (
|
||||
{sortedAccounts.length === 0 && (
|
||||
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||
@@ -653,11 +1130,11 @@ export function CloudAccountList() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleBatchRefresh}>
|
||||
<Button variant="secondary" size="sm" onClick={refreshSelectedAccounts}>
|
||||
<RefreshCw className="mr-2 h-3 w-3" />
|
||||
{t('cloud.batch.refresh')}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
|
||||
<Button variant="destructive" size="sm" onClick={deleteSelectedAccounts}>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
{t('cloud.batch.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||
@@ -10,81 +10,110 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2, Search, RotateCcw, Save } from 'lucide-react';
|
||||
import { filter, flatMap, includes, size, sortBy, sumBy, uniq, values } from 'lodash-es';
|
||||
import type { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
interface ModelVisibilitySettingsProps {}
|
||||
function collectAvailableModelIds(accounts: CloudAccount[] | undefined): string[] {
|
||||
if (!accounts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = () => {
|
||||
const modelNames = flatMap(accounts, (account) => {
|
||||
if (!account.quota?.models) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.keys(account.quota.models);
|
||||
});
|
||||
|
||||
return sortBy(uniq(modelNames));
|
||||
}
|
||||
|
||||
function filterModelIdsByQuery(modelIds: string[], query: string): string[] {
|
||||
const normalizedSearchQuery = query.toLowerCase();
|
||||
|
||||
return filter(modelIds, (modelId) => includes(modelId.toLowerCase(), normalizedSearchQuery));
|
||||
}
|
||||
|
||||
export function ModelVisibilitySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [modelVisibility, setModelVisibility] = useState<Record<string, boolean>>({});
|
||||
const [providerGroupingsEnabled, setProviderGroupingsEnabled] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [modelVisibilityMap, setModelVisibilityMap] = useState<Record<string, boolean>>({});
|
||||
const [providerGroupingEnabled, setProviderGroupingEnabled] = useState(false);
|
||||
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||
|
||||
// Initialize model visibility and provider groupings from config
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (config?.model_visibility) {
|
||||
setModelVisibility(config.model_visibility);
|
||||
setModelVisibilityMap(config.model_visibility);
|
||||
}
|
||||
if (config?.provider_groupings_enabled !== undefined) {
|
||||
setProviderGroupingsEnabled(config.provider_groupings_enabled);
|
||||
setProviderGroupingEnabled(config.provider_groupings_enabled);
|
||||
}
|
||||
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
||||
|
||||
// Get all unique models from all accounts
|
||||
const allModels = useMemo(() => {
|
||||
if (!accounts) return [];
|
||||
|
||||
const modelSet = new Set<string>();
|
||||
accounts.forEach((account) => {
|
||||
if (account.quota?.models) {
|
||||
Object.keys(account.quota.models).forEach((model) => {
|
||||
modelSet.add(model);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(modelSet).sort();
|
||||
const availableModelIds = useMemo(() => {
|
||||
return collectAvailableModelIds(accounts);
|
||||
}, [accounts]);
|
||||
|
||||
// Filter models based on search term
|
||||
const filteredModels = useMemo(() => {
|
||||
return allModels.filter((model) => model.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}, [allModels, searchTerm]);
|
||||
const filteredModelIds = useMemo(() => {
|
||||
return filterModelIdsByQuery(availableModelIds, searchQuery);
|
||||
}, [availableModelIds, searchQuery]);
|
||||
|
||||
// Handle model visibility toggle
|
||||
const handleModelToggle = (modelName: string, checked: boolean) => {
|
||||
setModelVisibility((prev) => ({
|
||||
...prev,
|
||||
[modelName]: checked,
|
||||
}));
|
||||
const hiddenModelCount = useMemo(() => {
|
||||
return sumBy(values(modelVisibilityMap), (isVisible) => (isVisible === false ? 1 : 0));
|
||||
}, [modelVisibilityMap]);
|
||||
|
||||
const isModelVisible = (modelId: string): boolean => {
|
||||
return modelVisibilityMap[modelId] !== false;
|
||||
};
|
||||
|
||||
// Reset to defaults (all models visible)
|
||||
const handleReset = () => {
|
||||
setModelVisibility({});
|
||||
const resetVisibilityOverrides = () => {
|
||||
setModelVisibilityMap({});
|
||||
};
|
||||
|
||||
// Save configuration
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
const saveVisibilitySettings = async () => {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setIsSavingSettings(true);
|
||||
try {
|
||||
await saveConfig({
|
||||
const nextConfig = {
|
||||
...config,
|
||||
model_visibility: modelVisibility,
|
||||
provider_groupings_enabled: providerGroupingsEnabled,
|
||||
});
|
||||
model_visibility: modelVisibilityMap,
|
||||
provider_groupings_enabled: providerGroupingEnabled,
|
||||
};
|
||||
await saveConfig(nextConfig);
|
||||
} catch (error) {
|
||||
console.error('Failed to save model visibility settings:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsSavingSettings(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchQueryChange = (nextQuery: string) => {
|
||||
setSearchQuery(nextQuery);
|
||||
};
|
||||
|
||||
const handleProviderGroupingToggle = (checked: boolean) => {
|
||||
setProviderGroupingEnabled(checked);
|
||||
};
|
||||
|
||||
const handleModelVisibilityChange = (modelId: string, checked: boolean) => {
|
||||
setModelVisibilityMap((prev) => ({
|
||||
...prev,
|
||||
[modelId]: checked,
|
||||
}));
|
||||
};
|
||||
|
||||
if (accountsLoading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -101,7 +130,7 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>{t('settings.modelVisibility.title')}</span>
|
||||
<Badge variant="secondary">{filteredModels.length} models</Badge>
|
||||
<Badge variant="secondary">{filteredModelIds.length} models</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -111,8 +140,8 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder={t('settings.modelVisibility.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchQueryChange(event.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
@@ -129,37 +158,39 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
</div>
|
||||
<Switch
|
||||
id="provider-groupings"
|
||||
checked={providerGroupingsEnabled}
|
||||
onCheckedChange={setProviderGroupingsEnabled}
|
||||
checked={providerGroupingEnabled}
|
||||
onCheckedChange={handleProviderGroupingToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model List */}
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-4">
|
||||
{filteredModels.length === 0 ? (
|
||||
{filteredModelIds.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
{searchTerm
|
||||
{searchQuery
|
||||
? t('settings.modelVisibility.noModelsFound')
|
||||
: t('settings.modelVisibility.noModels')}
|
||||
</div>
|
||||
) : (
|
||||
filteredModels.map((modelName) => {
|
||||
const isVisible = modelVisibility[modelName] !== false; // Default to true
|
||||
filteredModelIds.map((modelId) => {
|
||||
const isVisible = isModelVisible(modelId);
|
||||
return (
|
||||
<div
|
||||
key={modelName}
|
||||
key={modelId}
|
||||
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`model-${modelName}`}
|
||||
id={`model-${modelId}`}
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => handleModelToggle(modelName, checked as boolean)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleModelVisibilityChange(modelId, checked as boolean)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`model-${modelName}`}
|
||||
htmlFor={`model-${modelId}`}
|
||||
className="flex-1 cursor-pointer text-sm font-medium"
|
||||
>
|
||||
{modelName}
|
||||
{modelId}
|
||||
</label>
|
||||
{!isVisible && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -176,16 +207,26 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={Object.keys(modelVisibility).length === 0}
|
||||
onClick={resetVisibilityOverrides}
|
||||
disabled={size(modelVisibilityMap) === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
{t('settings.modelVisibility.reset')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving} className="flex items-center gap-2">
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{isSaving ? t('settings.modelVisibility.saving') : t('settings.modelVisibility.save')}
|
||||
<Button
|
||||
onClick={saveVisibilitySettings}
|
||||
disabled={isSavingSettings}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isSavingSettings ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{isSavingSettings
|
||||
? t('settings.modelVisibility.saving')
|
||||
: t('settings.modelVisibility.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -193,19 +234,18 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
||||
<div className="text-muted-foreground border-t pt-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>
|
||||
{t('settings.modelVisibility.totalModels')}: {allModels.length}
|
||||
{t('settings.modelVisibility.totalModels')}: {availableModelIds.length}
|
||||
</span>
|
||||
<span>
|
||||
{t('settings.modelVisibility.visibleModels')}:{' '}
|
||||
{allModels.length - Object.values(modelVisibility).filter((v) => !v).length}
|
||||
{availableModelIds.length - hiddenModelCount}
|
||||
</span>
|
||||
<span>
|
||||
{t('settings.modelVisibility.hiddenModels')}:{' '}
|
||||
{Object.values(modelVisibility).filter((v) => !v).length}
|
||||
{t('settings.modelVisibility.hiddenModels')}: {hiddenModelCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -4,16 +4,17 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { ProviderStats } from '@/utils/provider-grouping';
|
||||
import { clampQuotaPercentage } from '@/utils/quota-display';
|
||||
|
||||
interface ProviderGroupProps {
|
||||
stats: ProviderStats;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
getQuotaColor: (percentage: number) => string;
|
||||
getQuotaBarColor: (percentage: number) => string;
|
||||
getQuotaLabel: (percentage: number) => string;
|
||||
getResetTimeLabel: (resetTime?: string) => string;
|
||||
getResetTimeTitle: (resetTime?: string) => string | undefined;
|
||||
getQuotaTextColorClass: (percentage: number) => string;
|
||||
getQuotaBarColorClass: (percentage: number) => string;
|
||||
formatQuotaLabel: (percentage: number) => string;
|
||||
formatResetTimeLabel: (resetTime?: string) => string;
|
||||
formatResetTimeTitle: (resetTime?: string) => string | undefined;
|
||||
leftLabel: string;
|
||||
}
|
||||
|
||||
@@ -21,11 +22,11 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
stats,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
getQuotaColor,
|
||||
getQuotaBarColor,
|
||||
getQuotaLabel,
|
||||
getResetTimeLabel,
|
||||
getResetTimeTitle,
|
||||
getQuotaTextColorClass,
|
||||
getQuotaBarColorClass,
|
||||
formatQuotaLabel,
|
||||
formatResetTimeLabel,
|
||||
formatResetTimeTitle,
|
||||
leftLabel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -69,16 +70,18 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
{earliestReset && (
|
||||
<span
|
||||
className="text-muted-foreground text-[10px]"
|
||||
title={getResetTimeTitle(earliestReset)}
|
||||
title={formatResetTimeTitle(earliestReset)}
|
||||
>
|
||||
{getResetTimeLabel(earliestReset)}
|
||||
{formatResetTimeLabel(earliestReset)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`font-mono text-xs font-bold ${getQuotaColor(avgPercentage)}`}>
|
||||
{getQuotaLabel(avgPercentage)}
|
||||
<span
|
||||
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}
|
||||
>
|
||||
{formatQuotaLabel(avgPercentage)}
|
||||
</span>
|
||||
{avgPercentage > 0 && (
|
||||
{Number.isFinite(avgPercentage) && avgPercentage > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{t('settings.providerGroupings.avgLabel')}
|
||||
</span>
|
||||
@@ -86,8 +89,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(avgPercentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, avgPercentage))}%` }}
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,15 +112,15 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className="text-muted-foreground text-[10px] leading-none"
|
||||
title={getResetTimeTitle(model.resetTime)}
|
||||
title={formatResetTimeTitle(model.resetTime)}
|
||||
>
|
||||
{getResetTimeLabel(model.resetTime)}
|
||||
{formatResetTimeLabel(model.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${getQuotaColor(model.percentage)}`}
|
||||
className={`font-mono leading-none font-bold ${getQuotaTextColorClass(model.percentage)}`}
|
||||
>
|
||||
{getQuotaLabel(model.percentage)}
|
||||
{formatQuotaLabel(model.percentage)}
|
||||
</span>
|
||||
{model.percentage > 0 && (
|
||||
<span className="text-muted-foreground text-[10px]">{leftLabel}</span>
|
||||
@@ -125,8 +128,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(model.percentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, model.percentage))}%` }}
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { isOAuthReauthReason, isRateLimitReason } from '@/utils/account-status';
|
||||
|
||||
export function getValidationBlockedStatusLabel(
|
||||
status: 'active' | 'rate_limited' | 'expired' | undefined,
|
||||
reason: string | undefined,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
const normalizedReason = (reason || '').toLowerCase();
|
||||
const hasReason = normalizedReason !== '';
|
||||
const isBlockedByStatus = status === 'rate_limited' || status === 'expired';
|
||||
|
||||
if (!isBlockedByStatus && !hasReason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'rate_limited' || isRateLimitReason(normalizedReason)) {
|
||||
return t('cloud.card.validationRiskControlled');
|
||||
}
|
||||
|
||||
if (status === 'expired' || isOAuthReauthReason(normalizedReason)) {
|
||||
return t('cloud.card.validationOAuthReauthRequired');
|
||||
}
|
||||
|
||||
return t('cloud.card.validationRequired');
|
||||
}
|
||||
@@ -48,21 +48,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
|
||||
}
|
||||
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
|
||||
if (typeof collisionPadding === 'number') {
|
||||
if (collisionPadding < 0) {
|
||||
throw new Error('collisionPadding must be a non-negative number');
|
||||
}
|
||||
} else if (typeof collisionPadding === 'object' && collisionPadding !== null) {
|
||||
for (const key of Object.keys(collisionPadding)) {
|
||||
if ((collisionPadding as any)[key] < 0) {
|
||||
throw new Error(`collisionPadding.${key} must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
addGoogleAccount,
|
||||
deleteCloudAccount,
|
||||
refreshAccountQuota,
|
||||
setAccountProxy,
|
||||
listOAuthClients,
|
||||
setActiveOAuthClient,
|
||||
type OAuthClientDescriptor,
|
||||
} from '@/actions/cloud';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
cloudAccounts: ['cloudAccounts'],
|
||||
oauthClients: ['oauthClients'],
|
||||
};
|
||||
|
||||
export function useCloudAccounts() {
|
||||
@@ -30,6 +35,24 @@ export function useAddGoogleAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useOAuthClients() {
|
||||
return useQuery<OAuthClientDescriptor[]>({
|
||||
queryKey: QUERY_KEYS.oauthClients,
|
||||
queryFn: listOAuthClients,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetActiveOAuthClient() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setActiveOAuthClient,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.oauthClients });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCloudAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -120,3 +143,41 @@ export function useSyncLocalAccount() {
|
||||
|
||||
import { startAuthFlow } from '@/actions/cloud';
|
||||
export { startAuthFlow };
|
||||
|
||||
export function useSetAccountProxy() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setAccountProxy,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('[Mutation] setAccountProxy failed:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
import { exportCloudAccounts, importCloudAccounts } from '@/actions/cloud';
|
||||
|
||||
export function useExportCloudAccounts() {
|
||||
return useMutation<string, Error, { stripTokens?: boolean }>({
|
||||
mutationFn: exportCloudAccounts,
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportCloudAccounts() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
{ imported: number; skipped: number; updated: number; errors: string[] },
|
||||
Error,
|
||||
{ jsonContent: string; strategy?: 'merge' | 'overwrite' | 'skip-existing' }
|
||||
>({
|
||||
mutationFn: importCloudAccounts,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('[Mutation] importCloudAccounts failed:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
+89
-36
@@ -1,38 +1,79 @@
|
||||
import http from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { ipcContext } from '../context';
|
||||
import { escapeHtml } from '../../utils/url';
|
||||
|
||||
export class AuthServer {
|
||||
private static server: http.Server | null = null;
|
||||
private static PORT = 8888;
|
||||
|
||||
static start() {
|
||||
static async start() {
|
||||
if (this.server) {
|
||||
logger.warn('AuthServer: Server already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
const tryPorts = [8888, 8889, 8890, 8891, 8892];
|
||||
let boundPort: number | null = null;
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
for (const port of tryPorts) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const testServer = http.createServer();
|
||||
testServer.once('error', reject);
|
||||
testServer.listen(port, '127.0.0.1', () => {
|
||||
testServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
boundPort = port;
|
||||
break;
|
||||
} catch {
|
||||
logger.debug(`AuthServer: Port ${port} is in use, trying next...`);
|
||||
}
|
||||
}
|
||||
|
||||
if (code) {
|
||||
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
|
||||
if (!boundPort) {
|
||||
logger.error('AuthServer: No available ports found for OAuth callback server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
if (boundPort !== 8888) {
|
||||
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
this.PORT = boundPort;
|
||||
|
||||
try {
|
||||
this.server = http.createServer((req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405, { Allow: 'GET' });
|
||||
res.end('Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
||||
|
||||
if (url.pathname === '/oauth-callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (code) {
|
||||
const escapedCode = escapeHtml(code);
|
||||
logger.info(
|
||||
`AuthServer: Received authorization code: ${escapedCode.substring(0, 10)}...`,
|
||||
);
|
||||
|
||||
// Send code to renderer
|
||||
if (ipcContext.mainWindow) {
|
||||
logger.info('AuthServer: Sending code to renderer via IPC');
|
||||
ipcContext.mainWindow.webContents.send('GOOGLE_AUTH_CODE', code);
|
||||
logger.info('AuthServer: Code sent successfully');
|
||||
} else {
|
||||
logger.error('AuthServer: Main window not found, cannot send code');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||
<h1>Login Successful</h1>
|
||||
@@ -43,34 +84,46 @@ export class AuthServer {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else if (error) {
|
||||
logger.error(`AuthServer: OAuth error: ${error}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
} else if (error) {
|
||||
const escapedError = escapeHtml(error);
|
||||
logger.error(`AuthServer: OAuth error: ${escapedError}`);
|
||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h1>Login Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
<p>Error: ${escapedError}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('Missing code parameter');
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
});
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, '127.0.0.1', () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('AuthServer: Failed to create or start server', e);
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.server.on('error', (err) => {
|
||||
logger.error('AuthServer: Server error', err);
|
||||
});
|
||||
|
||||
this.server.listen(this.PORT, () => {
|
||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
||||
});
|
||||
static getRedirectUri(): string {
|
||||
return `http://localhost:${this.PORT}/oauth-callback`;
|
||||
}
|
||||
|
||||
static stop() {
|
||||
|
||||
+492
-47
@@ -1,7 +1,7 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import { GoogleAPIService, type OAuthClientDescriptor } from '../../services/GoogleAPIService';
|
||||
import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
import { shell } from 'electron';
|
||||
@@ -20,17 +20,7 @@ import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { runWithSwitchGuard } from '../../ipc/switchGuard';
|
||||
import { executeSwitchFlow } from '../../ipc/switchFlow';
|
||||
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
||||
|
||||
// Fallback constants if service constants are not available or for direct usage
|
||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const REDIRECT_URI = 'http://localhost:8888/oauth-callback';
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
'https://www.googleapis.com/auth/userinfo.profile',
|
||||
'https://www.googleapis.com/auth/cclog',
|
||||
'https://www.googleapis.com/auth/experimentsandconfigs',
|
||||
].join(' ');
|
||||
import { classifyAccountStatusFromError, extractErrorMessage } from '../../utils/account-status';
|
||||
|
||||
// Helper to update tray
|
||||
function notifyTrayUpdate(account: CloudAccount) {
|
||||
@@ -44,15 +34,223 @@ function notifyTrayUpdate(account: CloudAccount) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function addGoogleAccount(authCode: string): Promise<CloudAccount> {
|
||||
const ACTIVE_OAUTH_CLIENT_KEY_SETTING = 'active_oauth_client_key';
|
||||
const OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING = 'oauth_client_key_backfill_v1_done';
|
||||
const ENTERPRISE_OAUTH_CLIENT_KEY = 'antigravity_enterprise';
|
||||
|
||||
function isEnterpriseClient(clientKey?: string): boolean {
|
||||
if (!clientKey) {
|
||||
return false;
|
||||
}
|
||||
return clientKey.trim().toLowerCase() === ENTERPRISE_OAUTH_CLIENT_KEY;
|
||||
}
|
||||
|
||||
function normalizeProjectId(projectId?: string): string | null {
|
||||
if (typeof projectId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = projectId.trim();
|
||||
return normalized === '' ? null : normalized;
|
||||
}
|
||||
|
||||
function recoverCachedQuotaOnRateLimit(account: CloudAccount, error: unknown): CloudAccount['quota'] | null {
|
||||
const classified = classifyAccountStatusFromError(error);
|
||||
if (!classified || classified.status !== 'rate_limited') {
|
||||
return null;
|
||||
}
|
||||
if (!account.quota || !account.quota.models || Object.keys(account.quota.models).length === 0) {
|
||||
return null;
|
||||
}
|
||||
return account.quota;
|
||||
}
|
||||
|
||||
function formatSwitchRefreshError(reason: string): string {
|
||||
const normalized = reason.toLowerCase();
|
||||
if (
|
||||
normalized.includes('unauthorized_client') ||
|
||||
normalized.includes('invalid_client') ||
|
||||
normalized.includes('invalid_grant')
|
||||
) {
|
||||
return `Token refresh failed: OAuth client is not authorized for this account. Please re-login and complete authorization. Raw error: ${reason}`;
|
||||
}
|
||||
if (
|
||||
normalized.includes('verify your account') ||
|
||||
normalized.includes('further action is required') ||
|
||||
normalized.includes('validation required') ||
|
||||
normalized.includes('validation_url') ||
|
||||
normalized.includes('appeal_url')
|
||||
) {
|
||||
return `Token refresh failed: account requires additional verification. Please finish verification and retry. Raw error: ${reason}`;
|
||||
}
|
||||
if (
|
||||
normalized.includes('resource_exhausted') ||
|
||||
normalized.includes('resource has been exhausted')
|
||||
) {
|
||||
return `Token refresh failed: account is rate-limited or temporarily restricted (RESOURCE_EXHAUSTED). Please retry later. Raw error: ${reason}`;
|
||||
}
|
||||
return `Token refresh failed: ${reason}`;
|
||||
}
|
||||
|
||||
async function ensureEnterpriseProjectReady(account: CloudAccount): Promise<void> {
|
||||
if (!isEnterpriseClient(account.token.oauth_client_key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizeProjectId(account.token.project_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[OAuth] Account ${account.email} is using enterprise OAuth client but missing project_id. Resolving before switch...`,
|
||||
);
|
||||
|
||||
let resolvedProjectId: string | null = null;
|
||||
try {
|
||||
resolvedProjectId = await GoogleAPIService.fetchProjectId(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Account ${account.email} cannot be switched safely: missing enterprise project_id and auto-resolve failed (${extractErrorMessage(
|
||||
error,
|
||||
)}). Please re-login with a non-enterprise OAuth client or a project-enabled token.`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedProjectId = normalizeProjectId(resolvedProjectId ?? undefined);
|
||||
if (!normalizedProjectId) {
|
||||
throw new Error(
|
||||
`Account ${account.email} cannot be switched safely: enterprise OAuth requires a valid project_id.`,
|
||||
);
|
||||
}
|
||||
|
||||
account.token.project_id = normalizedProjectId;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
}
|
||||
|
||||
async function markAccountStatusFromError(account: CloudAccount, error: unknown): Promise<void> {
|
||||
const classified = classifyAccountStatusFromError(error);
|
||||
if (!classified) {
|
||||
return;
|
||||
}
|
||||
|
||||
account.status = classified.status;
|
||||
account.status_reason = classified.reason;
|
||||
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
|
||||
}
|
||||
|
||||
async function clearAccountStatus(account: CloudAccount): Promise<void> {
|
||||
account.status = 'active';
|
||||
account.status_reason = undefined;
|
||||
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
|
||||
}
|
||||
|
||||
function hydrateActiveOAuthClientFromSettings(): void {
|
||||
const preferredClientKey = CloudAccountRepo.getSetting<string>(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
|
||||
if (preferredClientKey && preferredClientKey.trim() !== '') {
|
||||
try {
|
||||
GoogleAPIService.setActiveOAuthClientKey(preferredClientKey);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OAuth] Stored active OAuth client '${preferredClientKey}' is invalid, falling back to default`,
|
||||
error,
|
||||
);
|
||||
CloudAccountRepo.setSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillMissingOAuthClientKeyForLegacyAccounts(
|
||||
accounts: CloudAccount[],
|
||||
): Promise<boolean> {
|
||||
const backfillDone = CloudAccountRepo.getSetting<boolean>(OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING, false);
|
||||
if (backfillDone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
const activeClientKey = GoogleAPIService.getActiveOAuthClientKey().trim().toLowerCase();
|
||||
if (activeClientKey === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedEnterpriseGuardCount = 0;
|
||||
let hasFailure = false;
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.provider !== 'google') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentClientKey = account.token.oauth_client_key?.trim();
|
||||
if (currentClientKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const refreshToken = account.token.refresh_token?.trim();
|
||||
if (!refreshToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectMissing =
|
||||
typeof account.token.project_id !== 'string' || account.token.project_id.trim() === '';
|
||||
if (activeClientKey === ENTERPRISE_OAUTH_CLIENT_KEY && projectMissing) {
|
||||
skippedEnterpriseGuardCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await CloudAccountRepo.updateToken(account.id, {
|
||||
...account.token,
|
||||
oauth_client_key: activeClientKey,
|
||||
});
|
||||
updatedCount += 1;
|
||||
} catch (error) {
|
||||
hasFailure = true;
|
||||
logger.warn(
|
||||
`[OAuth] Failed to backfill oauth_client_key for account ${account.email} (${account.id})`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasFailure) {
|
||||
CloudAccountRepo.setSetting(OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING, true);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[OAuth] Backfill oauth_client_key completed: updated=${updatedCount}, skipped_enterprise_guard=${skippedEnterpriseGuardCount}, has_failure=${hasFailure}`,
|
||||
);
|
||||
|
||||
return updatedCount > 0;
|
||||
}
|
||||
|
||||
export async function addGoogleAccount(
|
||||
authCode: string,
|
||||
oauthClientKey?: string,
|
||||
): Promise<CloudAccount> {
|
||||
try {
|
||||
if (oauthClientKey && oauthClientKey.trim() !== '') {
|
||||
setActiveOAuthClient(oauthClientKey);
|
||||
} else {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
}
|
||||
|
||||
// 1. Exchange code for tokens
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode);
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode, undefined, oauthClientKey);
|
||||
|
||||
// 2. Get User Info
|
||||
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
|
||||
|
||||
// 3. Construct CloudAccount Object
|
||||
// 3. Check for existing account
|
||||
const existing = await CloudAccountRepo.getAccountByEmail(userInfo.email);
|
||||
if (existing) {
|
||||
throw new Error(`Account with email ${userInfo.email} already exists.`);
|
||||
}
|
||||
|
||||
// 4. Construct CloudAccount Object
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const account: CloudAccount = {
|
||||
id: uuidv4(),
|
||||
@@ -62,11 +260,13 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
avatar_url: userInfo.picture,
|
||||
token: {
|
||||
access_token: tokenResp.access_token,
|
||||
refresh_token: tokenResp.refresh_token || '', // prompt=consent guarantees this, but we fallback safely
|
||||
refresh_token: tokenResp.refresh_token || '',
|
||||
expires_in: tokenResp.expires_in,
|
||||
expiry_timestamp: now + tokenResp.expires_in,
|
||||
token_type: tokenResp.token_type,
|
||||
email: userInfo.email,
|
||||
oauth_client_key: tokenResp.oauth_client_key,
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
@@ -76,14 +276,22 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
||||
}
|
||||
|
||||
// 4. Save to DB
|
||||
await CloudAccountRepo.addAccount(account);
|
||||
|
||||
// 5. Initial Quota Check (Async, best effort)
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(account.token.access_token);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else {
|
||||
logger.info(`No AI credits returned for ${account.email} during initial sync`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch initial AI credits', e);
|
||||
}
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
notifyTrayUpdate(account);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch initial quota', e);
|
||||
@@ -97,7 +305,12 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
||||
}
|
||||
|
||||
export async function listCloudAccounts(): Promise<CloudAccount[]> {
|
||||
return CloudAccountRepo.getAccounts();
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const backfilled = await backfillMissingOAuthClientKeyForLegacyAccounts(accounts);
|
||||
if (backfilled) {
|
||||
return CloudAccountRepo.getAccounts();
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
export async function deleteCloudAccount(accountId: string): Promise<void> {
|
||||
@@ -110,70 +323,150 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
throw new Error(`Account not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
let now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 300) {
|
||||
// 5 minutes buffer
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
|
||||
// Update token in memory object
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newTokenData.oauth_client_key,
|
||||
);
|
||||
|
||||
// Save to DB
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
|
||||
await markAccountStatusFromError(account, e);
|
||||
throw new Error(`Token refresh failed for ${account.email}. Please try logging in again.`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
const previousAICredits = account.quota?.ai_credits;
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token, account.proxy_url);
|
||||
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else {
|
||||
logger.info(`No AI credits returned for ${account.email} during quota refresh`);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch AI credits', e);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
await clearAccountStatus(account);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
if (error.message === 'UNAUTHORIZED') {
|
||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||
try {
|
||||
// Force Refresh
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
now = Math.floor(Date.now() / 1000);
|
||||
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newTokenData.oauth_client_key,
|
||||
);
|
||||
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
|
||||
// Retry Quota
|
||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||
const previousAICredits = account.quota?.ai_credits;
|
||||
const quota = await GoogleAPIService.fetchQuota(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||
account.token.access_token,
|
||||
account.proxy_url,
|
||||
);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else {
|
||||
logger.info(`No AI credits returned for ${account.email} after token refresh`);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch AI credits after token refresh', e);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
|
||||
account.quota = quota;
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000); // Sync memory object
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
await clearAccountStatus(account);
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
`Failed to force refresh token or retry quota for ${account.email}`,
|
||||
refreshError,
|
||||
);
|
||||
const cachedQuota = recoverCachedQuotaOnRateLimit(account, refreshError);
|
||||
if (cachedQuota) {
|
||||
logger.warn(
|
||||
`[OAuth] Quota request is rate-limited for ${account.email}; reusing cached quota as fallback.`,
|
||||
);
|
||||
await markAccountStatusFromError(account, refreshError);
|
||||
return account;
|
||||
}
|
||||
await markAccountStatusFromError(account, refreshError);
|
||||
throw refreshError;
|
||||
}
|
||||
} else if (error.message === 'FORBIDDEN') {
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
// Return existing account to avoid crash
|
||||
await markAccountStatusFromError(account, error);
|
||||
return account;
|
||||
}
|
||||
|
||||
const cachedQuota = recoverCachedQuotaOnRateLimit(account, error);
|
||||
if (cachedQuota) {
|
||||
logger.warn(
|
||||
`[OAuth] Quota request is rate-limited for ${account.email}; reusing cached quota as fallback.`,
|
||||
);
|
||||
await markAccountStatusFromError(account, error);
|
||||
return account;
|
||||
}
|
||||
|
||||
await markAccountStatusFromError(account, error);
|
||||
logger.error(`Failed to refresh quota for ${account.email}`, error);
|
||||
throw error;
|
||||
}
|
||||
@@ -200,32 +493,47 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
// 1. Prepare token refresh promise (start it in parallel with process exit)
|
||||
const tokenRefreshPromise = (async () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (account.token.expiry_timestamp < now + 1200) { // Increased buffer to 20m
|
||||
if (account.token.expiry_timestamp < now + 1200) {
|
||||
logger.info(`Token for ${account.email} near expiry, refreshing in parallel...`);
|
||||
try {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = newTokenData.expires_in;
|
||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
|
||||
const updatedToken = {
|
||||
...account.token,
|
||||
access_token: newTokenData.access_token,
|
||||
expires_in: newTokenData.expires_in,
|
||||
expiry_timestamp: now + newTokenData.expires_in,
|
||||
oauth_client_key: GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newTokenData.oauth_client_key,
|
||||
),
|
||||
};
|
||||
await CloudAccountRepo.updateToken(account.id, updatedToken);
|
||||
|
||||
account.token = updatedToken;
|
||||
logger.info(`Token refreshed for ${account.email}`);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refresh token in parallel, will try to use existing', e);
|
||||
logger.warn('Failed to refresh token in parallel', e);
|
||||
await markAccountStatusFromError(account, e);
|
||||
const reason = extractErrorMessage(e);
|
||||
throw new Error(formatSwitchRefreshError(reason));
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
await tokenRefreshPromise;
|
||||
await ensureEnterpriseProjectReady(account);
|
||||
|
||||
await executeSwitchFlow({
|
||||
scope: 'cloud',
|
||||
targetProfile: account.device_profile || null,
|
||||
applyFingerprint: isIdentityProfileApplyEnabled(),
|
||||
processExitTimeoutMs: 10000,
|
||||
performSwitch: async () => {
|
||||
// Wait for token refresh to complete before injection if it was started
|
||||
await tokenRefreshPromise;
|
||||
|
||||
// 3. Backup Database (Optimized to avoid race conditions)
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
for (const dbPath of dbPaths) {
|
||||
@@ -247,6 +555,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
// 5. Update usage and active status
|
||||
CloudAccountRepo.updateLastUsed(account.id);
|
||||
CloudAccountRepo.setActive(account.id);
|
||||
await clearAccountStatus(account);
|
||||
|
||||
logger.info(`Successfully switched to cloud account: ${account.email}`);
|
||||
notifyTrayUpdate(account);
|
||||
@@ -374,9 +683,145 @@ export async function forcePollCloudMonitor(): Promise<void> {
|
||||
await CloudMonitorService.poll();
|
||||
}
|
||||
|
||||
export async function startAuthFlow(): Promise<void> {
|
||||
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`;
|
||||
export async function startAuthFlow(oauthClientKey?: string): Promise<void> {
|
||||
if (oauthClientKey && oauthClientKey.trim() !== '') {
|
||||
setActiveOAuthClient(oauthClientKey);
|
||||
} else {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
}
|
||||
const url = GoogleAPIService.getAuthUrl(oauthClientKey);
|
||||
|
||||
logger.info(`Starting auth flow, opening URL: ${url}`);
|
||||
await shell.openExternal(url);
|
||||
}
|
||||
|
||||
export function listOAuthClients(): OAuthClientDescriptor[] {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
return GoogleAPIService.listOAuthClients();
|
||||
}
|
||||
|
||||
export function getActiveOAuthClient(): string {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
return GoogleAPIService.getActiveOAuthClientKey();
|
||||
}
|
||||
|
||||
export function setActiveOAuthClient(clientKey: string): void {
|
||||
GoogleAPIService.setActiveOAuthClientKey(clientKey);
|
||||
CloudAccountRepo.setSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, GoogleAPIService.getActiveOAuthClientKey());
|
||||
}
|
||||
|
||||
export async function exportCloudAccounts(stripTokens = false): Promise<string> {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const exportData = {
|
||||
version: '1.0' as const,
|
||||
exportedAt: Math.floor(Date.now() / 1000),
|
||||
accounts: accounts.map((acc) => ({
|
||||
provider: acc.provider,
|
||||
email: acc.email,
|
||||
name: acc.name,
|
||||
avatar_url: acc.avatar_url,
|
||||
token: stripTokens ? undefined : acc.token,
|
||||
quota: acc.quota,
|
||||
device_profile: acc.device_profile,
|
||||
device_history: acc.device_history,
|
||||
proxy_url: acc.proxy_url ?? null,
|
||||
status: acc.status,
|
||||
status_reason: acc.status_reason,
|
||||
})),
|
||||
};
|
||||
|
||||
CloudAccountExportSchema.parse(exportData);
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
export type ImportStrategy = 'merge' | 'overwrite' | 'skip-existing';
|
||||
|
||||
export async function importCloudAccounts(
|
||||
jsonContent: string,
|
||||
strategy: ImportStrategy = 'merge',
|
||||
): Promise<{ imported: number; skipped: number; updated: number; errors: string[] }> {
|
||||
const result = { imported: 0, skipped: 0, updated: 0, errors: [] as string[] };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonContent);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON format');
|
||||
}
|
||||
|
||||
const validated = CloudAccountExportSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
throw new Error(
|
||||
`Invalid export file: ${validated.error.issues[0]?.message || 'schema mismatch'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const importEmails = new Set<string>();
|
||||
for (const acc of validated.data.accounts) {
|
||||
const emailLower = acc.email.toLowerCase();
|
||||
if (importEmails.has(emailLower)) {
|
||||
throw new Error(`Duplicate email found in import file: ${acc.email}`);
|
||||
}
|
||||
importEmails.add(emailLower);
|
||||
}
|
||||
|
||||
const existingAccounts = await CloudAccountRepo.getAccounts();
|
||||
const existingByEmail = new Map(existingAccounts.map((a) => [a.email.toLowerCase(), a]));
|
||||
|
||||
for (const acc of validated.data.accounts) {
|
||||
try {
|
||||
const existing = existingByEmail.get(acc.email.toLowerCase());
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (existing) {
|
||||
if (strategy === 'skip-existing') {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedAccount: CloudAccount = {
|
||||
...existing,
|
||||
provider: acc.provider,
|
||||
name: acc.name ?? existing.name,
|
||||
avatar_url: acc.avatar_url ?? existing.avatar_url,
|
||||
token: acc.token,
|
||||
quota: acc.quota ?? existing.quota,
|
||||
device_profile: acc.device_profile ?? existing.device_profile,
|
||||
device_history: acc.device_history ?? existing.device_history,
|
||||
proxy_url: acc.proxy_url ?? existing.proxy_url,
|
||||
status: acc.status ?? existing.status,
|
||||
status_reason: acc.status_reason ?? existing.status_reason,
|
||||
last_used: now,
|
||||
};
|
||||
|
||||
await CloudAccountRepo.addAccount(updatedAccount);
|
||||
result.updated++;
|
||||
} else {
|
||||
const newAccount: CloudAccount = {
|
||||
id: uuidv4(),
|
||||
provider: acc.provider,
|
||||
email: acc.email,
|
||||
name: acc.name,
|
||||
avatar_url: acc.avatar_url,
|
||||
token: acc.token,
|
||||
quota: acc.quota,
|
||||
device_profile: acc.device_profile,
|
||||
device_history: acc.device_history,
|
||||
proxy_url: acc.proxy_url ?? undefined,
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
status: acc.status ?? 'active',
|
||||
status_reason: acc.status_reason,
|
||||
is_active: false,
|
||||
};
|
||||
|
||||
await CloudAccountRepo.addAccount(newAccount);
|
||||
result.imported++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to import ${acc.email}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+124
-7
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { os, ORPCError } from '@orpc/server';
|
||||
import {
|
||||
addGoogleAccount,
|
||||
bindCloudIdentityProfile,
|
||||
@@ -18,10 +18,15 @@ import {
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
listOAuthClients,
|
||||
getActiveOAuthClient,
|
||||
setActiveOAuthClient,
|
||||
exportCloudAccounts,
|
||||
importCloudAccounts,
|
||||
} from './handler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||
@@ -65,12 +70,44 @@ const switchStatusSnapshotSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, undefined> {
|
||||
const message = extractErrorMessage(error);
|
||||
const normalized = message.toLowerCase();
|
||||
|
||||
if (
|
||||
normalized.includes('unauthenticated') ||
|
||||
normalized.includes('unauthorized') ||
|
||||
normalized.includes('token may be expired') ||
|
||||
normalized.includes('re-login in antigravity ide')
|
||||
) {
|
||||
return new ORPCError('UNAUTHORIZED', { message });
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('no cloud account found in ide') ||
|
||||
normalized.includes('no oauth token found in ide state') ||
|
||||
normalized.includes('antigravity database not found')
|
||||
) {
|
||||
return new ORPCError('BAD_REQUEST', { message });
|
||||
}
|
||||
|
||||
return new ORPCError('INTERNAL_SERVER_ERROR', { message });
|
||||
}
|
||||
|
||||
export const cloudRouter = os.router({
|
||||
addGoogleAccount: os
|
||||
.input(z.object({ authCode: z.string() }))
|
||||
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode);
|
||||
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
@@ -113,10 +150,54 @@ export const cloudRouter = os.router({
|
||||
await forcePollCloudMonitor();
|
||||
}),
|
||||
|
||||
startAuthFlow: os.output(z.void()).handler(async () => {
|
||||
await startAuthFlow();
|
||||
startAuthFlow: os
|
||||
.input(z.object({ oauthClientKey: z.string().optional() }).optional())
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
await startAuthFlow(input?.oauthClientKey);
|
||||
}),
|
||||
|
||||
listOAuthClients: os
|
||||
.output(
|
||||
z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
client_id: z.string(),
|
||||
is_active: z.boolean(),
|
||||
is_builtin: z.boolean(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handler(async () => {
|
||||
return listOAuthClients();
|
||||
}),
|
||||
|
||||
getActiveOAuthClient: os.output(z.object({ client_key: z.string() })).handler(async () => {
|
||||
return {
|
||||
client_key: getActiveOAuthClient(),
|
||||
};
|
||||
}),
|
||||
|
||||
setActiveOAuthClient: os
|
||||
.input(z.object({ clientKey: z.string() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
setActiveOAuthClient(input.clientKey);
|
||||
}),
|
||||
|
||||
setAccountProxy: os
|
||||
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
|
||||
.output(z.void())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] setAccountProxy error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
@@ -124,7 +205,7 @@ export const cloudRouter = os.router({
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
throw error;
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -185,4 +266,40 @@ export const cloudRouter = os.router({
|
||||
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||
await openCloudIdentityStorageFolder();
|
||||
}),
|
||||
|
||||
exportCloudAccounts: os
|
||||
.input(z.object({ stripTokens: z.boolean().default(false) }))
|
||||
.output(z.string())
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
importCloudAccounts: os
|
||||
.input(
|
||||
z.object({
|
||||
jsonContent: z.string(),
|
||||
strategy: z.enum(['merge', 'overwrite', 'skip-existing']).default('merge'),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
imported: z.number(),
|
||||
skipped: z.number(),
|
||||
updated: z.number(),
|
||||
errors: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', error.message, error.stack);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { AppConfig } from '../../types/config';
|
||||
import { ConfigManager } from './manager';
|
||||
import { syncAutoStart } from '../../utils/autoStart';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { setServerConfig } from '../../server/server-config';
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
return ConfigManager.loadConfig();
|
||||
@@ -13,6 +14,7 @@ export async function saveConfig(config: AppConfig): Promise<void> {
|
||||
// For now just save
|
||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||
await ConfigManager.saveConfig(config);
|
||||
setServerConfig(config.proxy);
|
||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||
if (previous.auto_startup !== config.auto_startup) {
|
||||
syncAutoStart(config);
|
||||
|
||||
@@ -6,7 +6,12 @@ import { desc, eq } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { CloudAccount } from '../../types/cloudAccount';
|
||||
import {
|
||||
CloudAccount,
|
||||
CloudAccountSchema,
|
||||
CloudQuotaDataSchema,
|
||||
CloudTokenDataSchema,
|
||||
} from '../../types/cloudAccount';
|
||||
import { type DeviceProfile, type DeviceProfileVersion } from '../../types/account';
|
||||
import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
||||
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||
@@ -80,6 +85,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used INTEGER NOT NULL,
|
||||
status TEXT DEFAULT 'active',
|
||||
status_reason TEXT,
|
||||
is_active INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
@@ -90,6 +96,8 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
||||
const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
||||
const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_json');
|
||||
const hasProxyUrl = tableInfo.some((col) => col.name === 'proxy_url');
|
||||
const hasStatusReason = tableInfo.some((col) => col.name === 'status_reason');
|
||||
if (!hasIsActive) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||
}
|
||||
@@ -99,6 +107,12 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
||||
if (!hasDeviceHistoryJson) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN device_history_json TEXT');
|
||||
}
|
||||
if (!hasProxyUrl) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN proxy_url TEXT');
|
||||
}
|
||||
if (!hasStatusReason) {
|
||||
db.exec('ALTER TABLE accounts ADD COLUMN status_reason TEXT');
|
||||
}
|
||||
|
||||
// Create index on email for faster lookups
|
||||
// Create index on email for faster lookups
|
||||
@@ -450,6 +464,9 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static async addAccount(account: CloudAccount): Promise<void> {
|
||||
// Validate account data before processing
|
||||
CloudAccountSchema.parse(account);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||
@@ -467,7 +484,9 @@ export class CloudAccountRepo {
|
||||
createdAt: account.created_at,
|
||||
lastUsed: account.last_used,
|
||||
status: account.status || 'active',
|
||||
statusReason: account.status_reason ?? null,
|
||||
isActive: account.is_active ? 1 : 0,
|
||||
proxyUrl: account.proxy_url ?? null,
|
||||
};
|
||||
|
||||
orm.transaction((tx) => {
|
||||
@@ -509,79 +528,87 @@ export class CloudAccountRepo {
|
||||
|
||||
const cloudAccounts: CloudAccount[] = [];
|
||||
for (const normalizedRow of rows) {
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||
continue; // Skip corrupted account
|
||||
}
|
||||
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
migrationStats.failedFields += 1;
|
||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||
quotaResult = { value: null, migrated: false }; // Quota is optional, proceed
|
||||
}
|
||||
|
||||
if (!tokenResult.value) {
|
||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
||||
}
|
||||
if (!tokenResult.value) {
|
||||
logger.warn(`Missing token data for account ${normalizedRow.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tokenResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (tokenResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (tokenResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (tokenResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (tokenResult.usedFallback) {
|
||||
migrationStats.migratedBySource[tokenResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (quotaResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (quotaResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (quotaResult.value) {
|
||||
migrationStats.totalFields += 1;
|
||||
}
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||
migrationStats.fallbackUsedFields += 1;
|
||||
}
|
||||
if (quotaResult.migrated) {
|
||||
migrationStats.migratedFields += 1;
|
||||
if (quotaResult.usedFallback) {
|
||||
migrationStats.migratedBySource[quotaResult.usedFallback] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cloudAccounts.push({
|
||||
id: normalizedRow.id,
|
||||
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
});
|
||||
cloudAccounts.push({
|
||||
id: normalizedRow.id,
|
||||
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
status_reason: normalizedRow.statusReason ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||
});
|
||||
} catch (rowError) {
|
||||
logger.error(`Unexpected error processing row for account ${normalizedRow.id}`, rowError);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return cloudAccounts;
|
||||
@@ -618,21 +645,41 @@ export class CloudAccountRepo {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
const quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
let tokenResult: DecryptFieldResult;
|
||||
try {
|
||||
tokenResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'tokenJson',
|
||||
normalizedRow.tokenJson,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[CloudAccountRepo] getAccount ${id} failed - Decryption failed for token`,
|
||||
error,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!tokenResult.value) {
|
||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
||||
let quotaResult: DecryptFieldResult;
|
||||
try {
|
||||
quotaResult = await decryptAndMigrateField(
|
||||
orm,
|
||||
normalizedRow.id,
|
||||
'quotaJson',
|
||||
normalizedRow.quotaJson,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[CloudAccountRepo] getAccount ${id} failed - Decryption failed for quota`,
|
||||
error,
|
||||
);
|
||||
quotaResult = { value: null, migrated: false };
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.value;
|
||||
if (!tokenValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -641,14 +688,16 @@ export class CloudAccountRepo {
|
||||
email: normalizedRow.email,
|
||||
name: normalizedRow.name ?? undefined,
|
||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||
token: JSON.parse(tokenResult.value),
|
||||
token: JSON.parse(tokenValue),
|
||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||
created_at: normalizedRow.createdAt,
|
||||
last_used: normalizedRow.lastUsed,
|
||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||
status_reason: normalizedRow.statusReason ?? undefined,
|
||||
is_active: Boolean(normalizedRow.isActive),
|
||||
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||
};
|
||||
} finally {
|
||||
raw.close();
|
||||
@@ -666,22 +715,42 @@ export class CloudAccountRepo {
|
||||
}
|
||||
|
||||
static async updateToken(id: string, token: any): Promise<void> {
|
||||
// Validate token data before encryption
|
||||
CloudTokenDataSchema.parse(token);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(token));
|
||||
orm.update(accounts).set({ tokenJson: encrypted }).where(eq(accounts.id, id)).run();
|
||||
const result = orm
|
||||
.update(accounts)
|
||||
.set({ tokenJson: encrypted })
|
||||
.where(eq(accounts.id, id))
|
||||
.run();
|
||||
if (result.changes === 0) {
|
||||
logger.warn(`updateToken: No account found with ID ${id}`);
|
||||
}
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||
// Validate quota data before encryption
|
||||
CloudQuotaDataSchema.parse(quota);
|
||||
|
||||
const { raw, orm } = getCloudDb();
|
||||
|
||||
try {
|
||||
const encrypted = await encrypt(JSON.stringify(quota));
|
||||
orm.update(accounts).set({ quotaJson: encrypted }).where(eq(accounts.id, id)).run();
|
||||
const result = orm
|
||||
.update(accounts)
|
||||
.set({ quotaJson: encrypted })
|
||||
.where(eq(accounts.id, id))
|
||||
.run();
|
||||
if (result.changes === 0) {
|
||||
logger.warn(`updateQuota: No account found with ID ${id}`);
|
||||
}
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
@@ -911,6 +980,44 @@ export class CloudAccountRepo {
|
||||
}
|
||||
}
|
||||
|
||||
static setAccountProxy(id: string, proxyUrl: string | null): void {
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
orm.update(accounts).set({ proxyUrl }).where(eq(accounts.id, id)).run();
|
||||
logger.info(`Updated proxy for account ${id}: ${proxyUrl ?? 'none'}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update proxy for account ${id}`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async setAccountStatus(
|
||||
id: string,
|
||||
status: CloudAccount['status'],
|
||||
reason?: string | null,
|
||||
): Promise<void> {
|
||||
const { raw, orm } = getCloudDb();
|
||||
try {
|
||||
orm
|
||||
.update(accounts)
|
||||
.set({
|
||||
status,
|
||||
statusReason: reason?.trim() ? reason.trim() : null,
|
||||
})
|
||||
.where(eq(accounts.id, id))
|
||||
.run();
|
||||
} finally {
|
||||
raw.close();
|
||||
}
|
||||
}
|
||||
|
||||
static async getAccountByEmail(email: string): Promise<CloudAccount | null> {
|
||||
const all = await this.getAccounts();
|
||||
return all.find((a) => a.email.toLowerCase() === email.toLowerCase()) || null;
|
||||
}
|
||||
|
||||
private static upsertItemValue(db: DrizzleExecutor, key: string, value: string): void {
|
||||
db.insert(itemTable)
|
||||
.values({ key, value })
|
||||
@@ -951,10 +1058,35 @@ export class CloudAccountRepo {
|
||||
account.token.access_token,
|
||||
account.token.refresh_token,
|
||||
account.token.expiry_timestamp,
|
||||
account.token.is_gcp_tos ?? true,
|
||||
);
|
||||
const userStatusPayload = ProtobufUtils.createMinimalUserStatusPayload(account.email);
|
||||
const userStatusEntry = ProtobufUtils.createUnifiedStateEntry(
|
||||
'userStatusSentinelKey',
|
||||
userStatusPayload,
|
||||
);
|
||||
const normalizedProjectId = account.token.project_id?.trim();
|
||||
|
||||
orm.transaction((tx) => {
|
||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.oauthToken', oauthToken);
|
||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.userStatus', userStatusEntry);
|
||||
if (normalizedProjectId) {
|
||||
const projectPayload = ProtobufUtils.createStringValuePayload(normalizedProjectId);
|
||||
const projectEntry = ProtobufUtils.createUnifiedStateEntry(
|
||||
'enterpriseGcpProjectId',
|
||||
projectPayload,
|
||||
);
|
||||
this.upsertItemValue(
|
||||
tx,
|
||||
'antigravityUnifiedStateSync.enterprisePreferences',
|
||||
projectEntry,
|
||||
);
|
||||
} else {
|
||||
tx
|
||||
.delete(itemTable)
|
||||
.where(eq(itemTable.key, 'antigravityUnifiedStateSync.enterprisePreferences'))
|
||||
.run();
|
||||
}
|
||||
this.writeAuthStatusAndCleanup(tx, account);
|
||||
});
|
||||
}
|
||||
@@ -1173,7 +1305,9 @@ export class CloudAccountRepo {
|
||||
private static readTokenInfoFromDb(db: DrizzleExecutor): {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
projectId?: string;
|
||||
} {
|
||||
const enterpriseProjectId = this.readEnterpriseProjectIdFromDb(db);
|
||||
const unifiedValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
@@ -1217,12 +1351,16 @@ export class CloudAccountRepo {
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return tokenInfo;
|
||||
return {
|
||||
...tokenInfo,
|
||||
projectId: enterpriseProjectId,
|
||||
};
|
||||
}
|
||||
|
||||
private static readTokenInfoWithRetry(dbPath: string): {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
projectId?: string;
|
||||
} {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) {
|
||||
@@ -1244,6 +1382,41 @@ export class CloudAccountRepo {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private static readEnterpriseProjectIdFromDb(db: DrizzleExecutor): string | undefined {
|
||||
const enterprisePreferencesValue = this.getItemValue(
|
||||
db,
|
||||
'antigravityUnifiedStateSync.enterprisePreferences',
|
||||
'ide.itemTable.antigravityUnifiedStateSync.enterprisePreferences',
|
||||
);
|
||||
if (!enterprisePreferencesValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { sentinelKey, payload } = ProtobufUtils.decodeUnifiedStateEntry(
|
||||
enterprisePreferencesValue,
|
||||
);
|
||||
if (sentinelKey !== 'enterpriseGcpProjectId') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const projectBytes = ProtobufUtils.getField(payload, 3);
|
||||
if (!projectBytes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const projectId = ProtobufUtils.readString(projectBytes).trim();
|
||||
if (projectId === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return projectId;
|
||||
} catch (error) {
|
||||
logger.warn('SyncLocal: Failed to parse enterprise project preference', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||
// Try all possible database paths
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
@@ -1293,6 +1466,8 @@ export class CloudAccountRepo {
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
project_id: tokenInfo.projectId,
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
@@ -1304,8 +1479,28 @@ export class CloudAccountRepo {
|
||||
const accounts = await this.getAccounts();
|
||||
const existing = accounts.find((a) => a.email === account.email);
|
||||
if (existing) {
|
||||
const existingProjectId = existing.token.project_id?.trim();
|
||||
|
||||
account.id = existing.id; // Keep existing ID
|
||||
account.created_at = existing.created_at;
|
||||
account.name = account.name ?? existing.name;
|
||||
account.avatar_url = account.avatar_url ?? existing.avatar_url;
|
||||
account.proxy_url = existing.proxy_url;
|
||||
account.device_profile = existing.device_profile;
|
||||
account.device_history = existing.device_history;
|
||||
account.status = 'active';
|
||||
account.status_reason = undefined;
|
||||
account.token = {
|
||||
...existing.token,
|
||||
access_token: tokenInfo.accessToken,
|
||||
refresh_token: tokenInfo.refreshToken || existing.token.refresh_token,
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: now + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: userInfo.email,
|
||||
project_id: existingProjectId || tokenInfo.projectId,
|
||||
is_gcp_tos: existing.token.is_gcp_tos ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
await this.addAccount(account);
|
||||
|
||||
@@ -12,8 +12,10 @@ export const accounts = sqliteTable('accounts', {
|
||||
deviceHistoryJson: text('device_history_json'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
lastUsed: integer('last_used').notNull(),
|
||||
status: text('status'),
|
||||
status: text('status', { enum: ['active', 'rate_limited', 'expired'] }).default('active'),
|
||||
statusReason: text('status_reason'),
|
||||
isActive: integer('is_active').notNull().default(0),
|
||||
proxyUrl: text('proxy_url'),
|
||||
});
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
|
||||
+128
-28
@@ -1,10 +1,13 @@
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import findProcess, { ProcessInfo } from 'find-process';
|
||||
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const PROCESS_STARTUP_TIMEOUT_MS = 6000;
|
||||
const PROCESS_STARTUP_POLL_INTERVAL_MS = 200;
|
||||
const LINUX_GPU_SAFE_LAUNCH_ARGS = ['--disable-gpu', '--disable-gpu-compositing'] as const;
|
||||
|
||||
/**
|
||||
* Helper process name patterns to exclude (Electron helper processes)
|
||||
@@ -73,19 +76,17 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
const currentPid = process.pid;
|
||||
|
||||
// Use find-process to search for Antigravity processes
|
||||
// 'name' search type matches process name
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
const allMatches: ProcessInfo[] = [];
|
||||
const searchNames = ['Antigravity', 'antigravity'];
|
||||
if (platform === 'linux') {
|
||||
searchNames.push('electron');
|
||||
}
|
||||
let sawNoMatch = false;
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
try {
|
||||
const matches = await findProcess('name', searchName, true);
|
||||
for (const proc of matches) {
|
||||
if (typeof proc.pid === 'number') {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
const matches = await findProcess('name', searchName, false);
|
||||
allMatches.push(...matches);
|
||||
} catch (error) {
|
||||
if (isPgrepNoMatchError(error)) {
|
||||
sawNoMatch = true;
|
||||
@@ -95,6 +96,13 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
const processMap = new Map<number, ProcessInfo>();
|
||||
for (const proc of allMatches) {
|
||||
if (typeof proc.pid === 'number') {
|
||||
processMap.set(proc.pid, proc);
|
||||
}
|
||||
}
|
||||
|
||||
const processes = Array.from(processMap.values());
|
||||
if (processes.length === 0 && sawNoMatch) {
|
||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||
@@ -145,7 +153,27 @@ export async function isProcessRunning(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Linux: Check for antigravity in name or path (but not tools)
|
||||
const nameLower = name.toLowerCase();
|
||||
const cmdLower = cmd.toLowerCase();
|
||||
|
||||
if (nameLower === 'electron') {
|
||||
// Stricter check for AUR/Electron wrapper:
|
||||
// Must include antigravity in command line but NOT manager or tools
|
||||
const isAntigravityApp =
|
||||
(cmdLower.includes('/antigravity') ||
|
||||
cmdLower.includes(' antigravity') ||
|
||||
cmdLower.endsWith('antigravity')) &&
|
||||
!cmdLower.includes('manager') &&
|
||||
!cmdLower.includes('tools');
|
||||
|
||||
if (isAntigravityApp) {
|
||||
logger.debug(
|
||||
`Found Antigravity (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||
!name.includes('tools')
|
||||
@@ -389,6 +417,73 @@ async function openUri(uri: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAntigravityStartup(
|
||||
timeoutMs = PROCESS_STARTUP_TIMEOUT_MS,
|
||||
pollIntervalMs = PROCESS_STARTUP_POLL_INTERVAL_MS,
|
||||
): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isProcessRunning()) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldUseLinuxGpuSafeLaunchArgs(): boolean {
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enableLinuxGpuRaw = process.env.ANTIGRAVITY_MANAGER_ENABLE_LINUX_GPU?.trim().toLowerCase();
|
||||
return enableLinuxGpuRaw !== '1' && enableLinuxGpuRaw !== 'true';
|
||||
}
|
||||
|
||||
async function startAntigravityByExecutable(execPath: string): Promise<void> {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Antigravity`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Antigravity executable path');
|
||||
}
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWsl()) {
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Antigravity executable path');
|
||||
}
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
|
||||
.replace(/\//g, '\\');
|
||||
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!execPath) {
|
||||
throw new Error('Unable to locate Antigravity executable path');
|
||||
}
|
||||
|
||||
const launchArgs = shouldUseLinuxGpuSafeLaunchArgs() ? [...LINUX_GPU_SAFE_LAUNCH_ARGS] : [];
|
||||
if (launchArgs.length > 0) {
|
||||
logger.info(`Linux launch with GPU-safe args: ${launchArgs.join(' ')}`);
|
||||
}
|
||||
|
||||
const child = spawn(execPath, launchArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Antigravity process.
|
||||
* @param useUri {boolean} Whether to use the URI protocol to start Antigravity.
|
||||
@@ -408,7 +503,19 @@ export async function startAntigravity(useUri = true): Promise<void> {
|
||||
|
||||
if (await openUri(uri)) {
|
||||
logger.info('Antigravity URI launch command sent');
|
||||
return;
|
||||
|
||||
if (process.platform !== 'linux' || isWsl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await waitForAntigravityStartup()) {
|
||||
logger.info('Antigravity process detected after URI launch');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
'URI launch did not keep Antigravity running on Linux. Falling back to executable launch.',
|
||||
);
|
||||
} else {
|
||||
logger.warn('URI launch failed, trying executable path...');
|
||||
}
|
||||
@@ -419,24 +526,17 @@ export async function startAntigravity(useUri = true): Promise<void> {
|
||||
const execPath = getAntigravityExecutablePath();
|
||||
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
await execAsync(`open -a Antigravity`);
|
||||
} else if (process.platform === 'win32') {
|
||||
// Use start command to detach
|
||||
await execAsync(`start "" "${execPath}"`);
|
||||
} else if (isWsl()) {
|
||||
// In WSL, convert path and use cmd.exe
|
||||
const winPath = execPath
|
||||
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
|
||||
.replace(/\//g, '\\');
|
||||
|
||||
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
|
||||
} else {
|
||||
// Linux native
|
||||
const child = exec(`"${execPath}"`);
|
||||
child.unref();
|
||||
}
|
||||
await startAntigravityByExecutable(execPath);
|
||||
logger.info('Antigravity launch command sent');
|
||||
|
||||
if (process.platform === 'linux' && !isWsl()) {
|
||||
const started = await waitForAntigravityStartup();
|
||||
if (!started) {
|
||||
logger.warn(
|
||||
'Antigravity launch command completed, but process startup could not be confirmed on Linux.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to start Antigravity via executable', error);
|
||||
throw error;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { mapClaudeModelToGemini } from './ModelMapping';
|
||||
import { cleanJsonSchema } from './JsonSchemaUtils';
|
||||
import { isPlainObject, isString } from 'lodash-es';
|
||||
import { mapClaudeModelToGemini, normalizeGeminiModelAlias } from './ModelMapping';
|
||||
import { getMaxOutputTokens, getThinkingBudget } from './ModelSpecs';
|
||||
import { cleanJsonSchema, normalizeObjectJsonSchema } from './JsonSchemaUtils';
|
||||
import { SignatureStore } from './SignatureStore';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
@@ -15,6 +17,11 @@ import {
|
||||
FunctionDeclaration,
|
||||
SafetySetting,
|
||||
} from './types';
|
||||
import {
|
||||
buildUserAgent,
|
||||
FALLBACK_VERSION,
|
||||
resolveLocalInstalledVersion,
|
||||
} from '@/server/modules/proxy/request-user-agent';
|
||||
|
||||
/**
|
||||
* Request Configuration
|
||||
@@ -44,6 +51,7 @@ interface RequestConfig {
|
||||
export function transformClaudeRequestIn(
|
||||
claudeReq: ClaudeRequest,
|
||||
projectId?: string,
|
||||
userAgent?: string,
|
||||
): GeminiInternalRequest {
|
||||
// Check for networking tools (server tool or built-in tool)
|
||||
const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools);
|
||||
@@ -55,9 +63,7 @@ export function transformClaudeRequestIn(
|
||||
const systemInstruction = buildSystemInstruction(claudeReq.system);
|
||||
|
||||
// Map model name
|
||||
const mappedModel = hasWebSearchTool
|
||||
? 'gemini-2.5-flash'
|
||||
: mapClaudeModelToGemini(claudeReq.model);
|
||||
const mappedModel = hasWebSearchTool ? 'gemini-3-flash' : mapClaudeModelToGemini(claudeReq.model);
|
||||
|
||||
// Convert Claude tools to Tool array for networking detection
|
||||
const toolsVal: Tool[] | undefined = claudeReq.tools
|
||||
@@ -70,7 +76,11 @@ export function transformClaudeRequestIn(
|
||||
const allowDummyThought = config.finalModel.startsWith('gemini-');
|
||||
|
||||
// 4. Generation Config & Thinking
|
||||
let isThinkingEnabled = claudeReq.thinking?.type === 'enabled';
|
||||
const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
const autoThinkingEnabled =
|
||||
!claudeReq.thinking && shouldEnableThinkingByDefault(config.finalModel, claudeReq.model);
|
||||
let isThinkingEnabled =
|
||||
thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled;
|
||||
|
||||
if (isThinkingEnabled) {
|
||||
const globalSig = SignatureStore.get();
|
||||
@@ -82,11 +92,18 @@ export function transformClaudeRequestIn(
|
||||
});
|
||||
|
||||
if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) {
|
||||
isThinkingEnabled = false;
|
||||
if (!isGeminiFlashModel(config.finalModel)) {
|
||||
isThinkingEnabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generationConfig = buildGenerationConfig(claudeReq, hasWebSearchTool, config.finalModel);
|
||||
const generationConfig = buildGenerationConfig(
|
||||
claudeReq,
|
||||
hasWebSearchTool,
|
||||
config.finalModel,
|
||||
isThinkingEnabled,
|
||||
);
|
||||
// Update thinking config based on the final decision
|
||||
if (!isThinkingEnabled && generationConfig.thinkingConfig) {
|
||||
delete generationConfig.thinkingConfig;
|
||||
@@ -98,10 +115,11 @@ export function transformClaudeRequestIn(
|
||||
toolIdToName,
|
||||
isThinkingEnabled,
|
||||
allowDummyThought,
|
||||
config.finalModel,
|
||||
);
|
||||
|
||||
// 3. Tools
|
||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool, config.finalModel);
|
||||
|
||||
// 5. Safety Settings
|
||||
const safetySettings = [
|
||||
@@ -142,7 +160,7 @@ export function transformClaudeRequestIn(
|
||||
|
||||
// Inject googleSearch tool if needed (and not already done by buildTools)
|
||||
if (config.injectGoogleSearch && !hasWebSearchTool) {
|
||||
injectGoogleSearchTool(innerRequest);
|
||||
injectGoogleSearchTool(innerRequest, config.finalModel);
|
||||
}
|
||||
|
||||
// Inject imageConfig if present (for image generation models)
|
||||
@@ -165,11 +183,12 @@ export function transformClaudeRequestIn(
|
||||
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const body: GeminiInternalRequest = {
|
||||
requestId: requestId,
|
||||
request: innerRequest as GeminiInternalRequest['request'],
|
||||
model: config.finalModel,
|
||||
userAgent: 'antigravity',
|
||||
userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion),
|
||||
requestType: config.requestType,
|
||||
};
|
||||
|
||||
@@ -194,7 +213,7 @@ function resolveRequestConfig(
|
||||
tools?: Tool[],
|
||||
): RequestConfig {
|
||||
// 1. Image Generation Check
|
||||
if (mappedModel.startsWith('gemini-3-pro-image')) {
|
||||
if (isGeminiImageModel(mappedModel)) {
|
||||
const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel);
|
||||
return {
|
||||
requestType: 'image_gen',
|
||||
@@ -212,18 +231,11 @@ function resolveRequestConfig(
|
||||
const enableNetworking = isOnlineSuffix || hasNetworkingTool;
|
||||
|
||||
let finalModel = mappedModel.replace(/-online$/, '');
|
||||
|
||||
if (finalModel === 'gemini-3-pro-preview') {
|
||||
finalModel = 'gemini-3-pro-high';
|
||||
} else if (finalModel === 'gemini-3-pro-image-preview') {
|
||||
finalModel = 'gemini-3-pro-image';
|
||||
} else if (finalModel === 'gemini-3-flash-preview') {
|
||||
finalModel = 'gemini-3-flash';
|
||||
}
|
||||
finalModel = normalizeGeminiModelAlias(finalModel);
|
||||
|
||||
if (enableNetworking) {
|
||||
if (finalModel !== 'gemini-2.5-flash') {
|
||||
finalModel = 'gemini-2.5-flash';
|
||||
if (finalModel !== 'gemini-3-flash') {
|
||||
finalModel = 'gemini-3-flash';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +272,50 @@ function parseImageConfig(modelName: string): {
|
||||
return { imageConfig: config, parsedBaseModel: 'gemini-3-pro-image' };
|
||||
}
|
||||
|
||||
function isGeminiImageModel(modelName: string): boolean {
|
||||
const normalized = modelName.toLowerCase();
|
||||
return (
|
||||
normalized.startsWith('gemini-3-pro-image') || normalized.startsWith('gemini-3.1-pro-image')
|
||||
);
|
||||
}
|
||||
|
||||
function isGeminiFlashModel(modelName: string): boolean {
|
||||
const normalized = modelName.toLowerCase();
|
||||
return normalized.includes('gemini-3-flash') || normalized.includes('gemini-3.1-flash');
|
||||
}
|
||||
|
||||
function shouldEnableThinkingByDefault(mappedModel: string, originalModel: string): boolean {
|
||||
const mappedLower = mappedModel.toLowerCase();
|
||||
const originalLower = originalModel.toLowerCase();
|
||||
return (
|
||||
originalLower.includes('claude-opus-4-5') ||
|
||||
originalLower.includes('claude-opus-4-6') ||
|
||||
mappedLower.includes('-thinking') ||
|
||||
mappedLower.includes('gemini-3.1-pro') ||
|
||||
mappedLower.includes('gemini-3-flash') ||
|
||||
mappedLower.includes('gemini-3.1-flash')
|
||||
);
|
||||
}
|
||||
|
||||
function isClaudeModel(modelName: string): boolean {
|
||||
return modelName.toLowerCase().includes('claude');
|
||||
}
|
||||
|
||||
function resolveAdaptiveThinkingLevel(claudeReq: ClaudeRequest): 'low' | 'medium' | 'high' {
|
||||
const effort = String(claudeReq.thinking?.effort ?? '').toLowerCase();
|
||||
if (effort === 'low') {
|
||||
return 'low';
|
||||
}
|
||||
if (effort === 'medium') {
|
||||
return 'medium';
|
||||
}
|
||||
return 'high';
|
||||
}
|
||||
|
||||
function toToolSchema(schema: unknown): Record<string, unknown> {
|
||||
return normalizeObjectJsonSchema(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if networking tools are present
|
||||
* Checks tool list for web search related tools
|
||||
@@ -274,20 +330,23 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea
|
||||
'google_search',
|
||||
'web_search_20250305',
|
||||
'google_search_retrieval',
|
||||
'builtin_web_search',
|
||||
];
|
||||
|
||||
for (const tool of tools) {
|
||||
// Claude Tool format
|
||||
if ('name' in tool && tool.name && keywords.includes(tool.name)) {
|
||||
const toolName = (tool as { name?: unknown }).name;
|
||||
if (isString(toolName) && keywords.includes(toolName)) {
|
||||
return true;
|
||||
}
|
||||
if ('type' in tool && tool.type && keywords.includes(tool.type)) {
|
||||
const toolType = (tool as { type?: unknown }).type;
|
||||
if (isString(toolType) && keywords.includes(toolType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OpenAI nested format (runtime check)
|
||||
const openaiTool = tool as { function?: { name?: string } };
|
||||
if (openaiTool.function?.name && keywords.includes(openaiTool.function.name)) {
|
||||
if (isString(openaiTool.function?.name) && keywords.includes(openaiTool.function.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -311,11 +370,7 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Google Search Tool
|
||||
* Adds googleSearch tool to the request
|
||||
*/
|
||||
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }, mappedModel?: string) {
|
||||
if (!body.tools) {
|
||||
body.tools = [];
|
||||
}
|
||||
@@ -323,7 +378,9 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
||||
|
||||
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
||||
if (hasFunctions) {
|
||||
logger.info('Skipping googleSearch injection due to existing functionDeclarations');
|
||||
logger.info(
|
||||
`[Claude-Request] Skipping googleSearch injection for ${mappedModel ?? 'unknown-model'} because functionDeclarations are present (v1internal incompatible)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -351,7 +408,7 @@ function buildSystemInstruction(
|
||||
let hasIdentityDirective = false;
|
||||
|
||||
if (system) {
|
||||
if (typeof system === 'string') {
|
||||
if (isString(system)) {
|
||||
if (system.includes(identityMarker)) {
|
||||
hasIdentityDirective = true;
|
||||
}
|
||||
@@ -370,7 +427,7 @@ function buildSystemInstruction(
|
||||
}
|
||||
|
||||
if (system) {
|
||||
if (typeof system === 'string') {
|
||||
if (isString(system)) {
|
||||
parts.push({ text: system });
|
||||
} else if (Array.isArray(system)) {
|
||||
for (const block of system) {
|
||||
@@ -437,6 +494,7 @@ function buildContents(
|
||||
toolIdToName: Map<string, string>,
|
||||
isThinkingEnabled: boolean,
|
||||
allowDummyThought: boolean,
|
||||
mappedModel: string,
|
||||
): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
let lastThoughtSignature: string | null = null;
|
||||
@@ -480,12 +538,16 @@ function buildContents(
|
||||
cleanJsonSchema(part);
|
||||
toolIdToName.set(block.id, block.name);
|
||||
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
|
||||
if (finalSig) part.thoughtSignature = finalSig;
|
||||
if (finalSig) {
|
||||
part.thoughtSignature = finalSig;
|
||||
} else if (isThinkingEnabled && isGeminiFlashModel(mappedModel)) {
|
||||
part.thoughtSignature = 'skip_thought_signature_validator';
|
||||
}
|
||||
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;
|
||||
if (isString(block.content)) mergedContent = block.content;
|
||||
else if (Array.isArray(block.content))
|
||||
mergedContent = block.content
|
||||
.filter((b: any) => b.type === 'text')
|
||||
@@ -524,6 +586,7 @@ function buildContents(
|
||||
function buildTools(
|
||||
tools: Tool[] | undefined,
|
||||
hasWebSearch: boolean,
|
||||
mappedModel: string,
|
||||
): GeminiToolDeclaration[] | null {
|
||||
if (!tools) {
|
||||
return null;
|
||||
@@ -535,14 +598,15 @@ function buildTools(
|
||||
if (
|
||||
tool.name === 'web_search' ||
|
||||
tool.name === 'google_search' ||
|
||||
tool.type === 'web_search_20250305'
|
||||
tool.name === 'builtin_web_search' ||
|
||||
tool.type === 'web_search_20250305' ||
|
||||
tool.type === 'builtin_web_search'
|
||||
) {
|
||||
hasGoogleSearch = true;
|
||||
continue;
|
||||
}
|
||||
if (tool.name) {
|
||||
const inputSchema = tool.input_schema || { type: 'object', properties: {} };
|
||||
cleanJsonSchema(inputSchema);
|
||||
const inputSchema = toToolSchema(tool.input_schema);
|
||||
functionDeclarations.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -551,15 +615,20 @@ function buildTools(
|
||||
}
|
||||
}
|
||||
|
||||
const toolObj: GeminiToolDeclaration = {};
|
||||
const toolList: GeminiToolDeclaration[] = [];
|
||||
if (functionDeclarations.length > 0) {
|
||||
toolObj.functionDeclarations = functionDeclarations;
|
||||
toolList.push({ functionDeclarations });
|
||||
if (hasGoogleSearch) {
|
||||
logger.info(
|
||||
`[Claude-Request] Skipping googleSearch injection for ${mappedModel} because functionDeclarations are present (v1internal incompatible)`,
|
||||
);
|
||||
}
|
||||
} else if (hasGoogleSearch) {
|
||||
toolObj.googleSearch = {};
|
||||
toolList.push({ googleSearch: {} });
|
||||
}
|
||||
|
||||
if (Object.keys(toolObj).length > 0) {
|
||||
return [toolObj];
|
||||
if (toolList.length > 0) {
|
||||
return toolList;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -572,32 +641,53 @@ function buildGenerationConfig(
|
||||
claudeReq: ClaudeRequest,
|
||||
hasWebSearch: boolean,
|
||||
mappedModel: string,
|
||||
isThinkingEnabled: boolean,
|
||||
): GenerationConfig {
|
||||
const source = String(claudeReq.metadata?.source || '').toLowerCase();
|
||||
const isOpenAIPath = source === 'openai';
|
||||
const config: GenerationConfig = {};
|
||||
const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
|
||||
const buildThinkingConfig = (): GenerationConfig['thinkingConfig'] => {
|
||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||
if (thinkingType === 'adaptive') {
|
||||
if (isClaudeModel(mappedModel)) {
|
||||
thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq);
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = 24576;
|
||||
}
|
||||
} else if (claudeReq.thinking?.budget_tokens) {
|
||||
let budget = claudeReq.thinking.budget_tokens;
|
||||
const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel);
|
||||
if (isFlash) {
|
||||
budget = Math.min(budget, 24576);
|
||||
}
|
||||
thinkingConfig.thinkingBudget = budget;
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel);
|
||||
}
|
||||
return thinkingConfig;
|
||||
};
|
||||
|
||||
if (isOpenAIPath) {
|
||||
config.temperature = claudeReq.temperature ?? 1.0;
|
||||
config.topP = claudeReq.top_p ?? 0.95;
|
||||
if (claudeReq.max_tokens !== undefined) {
|
||||
config.maxOutputTokens = claudeReq.max_tokens;
|
||||
} else {
|
||||
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
|
||||
}
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = claudeReq.stop_sequences;
|
||||
}
|
||||
if (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
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 (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
}
|
||||
if (claudeReq.temperature !== undefined) {
|
||||
config.temperature = claudeReq.temperature;
|
||||
@@ -623,7 +713,7 @@ function buildGenerationConfig(
|
||||
function deepCleanUndefined(obj: unknown): void {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(deepCleanUndefined);
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
} else if (isPlainObject(obj)) {
|
||||
const record = obj as Record<string, unknown>;
|
||||
Object.keys(record).forEach((key) => {
|
||||
if (record[key] === undefined) delete record[key];
|
||||
|
||||
@@ -165,21 +165,21 @@ class NonStreamingProcessor {
|
||||
let groundingText = '';
|
||||
|
||||
if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) {
|
||||
groundingText += `\n\n---\n**🔍 已为您搜索:** ${grounding.webSearchQueries.join(', ')}`;
|
||||
groundingText += `\n\n---\n**🔍 Searched for you:** ${grounding.webSearchQueries.join(', ')}`;
|
||||
}
|
||||
|
||||
if (grounding.groundingChunks) {
|
||||
const links: string[] = [];
|
||||
grounding.groundingChunks.forEach((chunk, index) => {
|
||||
if (chunk.web) {
|
||||
const title = chunk.web.title || '网页来源';
|
||||
const title = chunk.web.title || 'Web source';
|
||||
const uri = chunk.web.uri || '#';
|
||||
links.push(`[${index + 1}] [${title}](${uri})`);
|
||||
}
|
||||
});
|
||||
|
||||
if (links.length > 0) {
|
||||
groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n');
|
||||
groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,19 +173,19 @@ export class StreamingState {
|
||||
// Process grounding (web search) -> convert to Markdown text block
|
||||
let groundingText = '';
|
||||
if (this.webSearchQuery) {
|
||||
groundingText += `\n\n---\n**🔍 已为您搜索:** ${this.webSearchQuery}`;
|
||||
groundingText += `\n\n---\n**🔍 Searched for you:** ${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 title = chunk.web.title || 'Web source';
|
||||
const uri = chunk.web.uri || '#';
|
||||
links.push(`[${i + 1}] [${title}](${uri})`);
|
||||
}
|
||||
});
|
||||
if (links.length > 0) {
|
||||
groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n');
|
||||
groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,13 +32,37 @@ export function cleanJsonSchema(value: any) {
|
||||
cleanJsonSchemaRecursive(value);
|
||||
}
|
||||
|
||||
export function normalizeObjectJsonSchema(schema: unknown): Record<string, unknown> {
|
||||
const fallbackSchema: Record<string, unknown> = { type: 'object', properties: {} };
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return fallbackSchema;
|
||||
}
|
||||
|
||||
const normalizedSchema = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
cleanJsonSchema(normalizedSchema);
|
||||
|
||||
if (typeof normalizedSchema.type !== 'string') {
|
||||
normalizedSchema.type = 'object';
|
||||
}
|
||||
if (
|
||||
normalizedSchema.type === 'object' &&
|
||||
(!normalizedSchema.properties ||
|
||||
typeof normalizedSchema.properties !== 'object' ||
|
||||
Array.isArray(normalizedSchema.properties))
|
||||
) {
|
||||
normalizedSchema.properties = {};
|
||||
}
|
||||
|
||||
return normalizedSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively expand $ref
|
||||
*/
|
||||
function flattenRefs(map: any, defs: Record<string, any>) {
|
||||
if (!map || typeof map !== 'object') return;
|
||||
|
||||
// 检查并替换 $ref
|
||||
// Check and replace $ref
|
||||
if (typeof map['$ref'] === 'string') {
|
||||
const refPath = map['$ref'];
|
||||
// Parse reference name (e.g. #/$defs/MyType -> MyType)
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const PUBLIC_SUPPORTED_MODELS = [
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3-flash',
|
||||
'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-thinking',
|
||||
] as const;
|
||||
|
||||
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||
// Directly supported models
|
||||
'claude-opus-4-5-thinking': 'claude-opus-4-5-thinking',
|
||||
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-thinking': 'claude-opus-4-6-thinking',
|
||||
'claude-sonnet-4-5': 'claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
||||
'gemini-3.1-pro-low': 'gemini-3.1-pro-low',
|
||||
'gemini-3.1-pro-high': 'gemini-3.1-pro-high',
|
||||
'gemini-3-flash': 'gemini-3-flash',
|
||||
|
||||
// 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-sonnet-4-6': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-5-20251101': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-6': 'claude-opus-4-6-thinking',
|
||||
'claude-opus-4-6-20260201': 'claude-opus-4-6-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',
|
||||
'claude-haiku-4': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-haiku-20240307': 'claude-sonnet-4-6-thinking',
|
||||
'claude-haiku-4-5-20251001': 'claude-sonnet-4-6-thinking',
|
||||
|
||||
// OpenAI Protocol Mapping
|
||||
'gpt-4': 'gemini-3-flash',
|
||||
@@ -42,12 +56,17 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||
|
||||
// Gemini Protocol Mapping
|
||||
'gemini-2.5-flash-lite': 'gemini-3-flash',
|
||||
'gemini-3-pro-low': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
||||
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-3.0-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-3-pro': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
||||
'gemini-3-pro-low': 'gemini-3-pro-low',
|
||||
'gemini-3-pro-high': 'gemini-3-pro-high',
|
||||
'gemini-2.5-flash': 'gemini-3-flash',
|
||||
'gemini-3-flash': 'gemini-3-flash',
|
||||
'gemini-2.5-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-2.0-flash': 'gemini-3-flash',
|
||||
'gemini-2.0-flash-online': 'gemini-3-flash',
|
||||
'gemini-3-pro-image': 'gemini-3-pro-image',
|
||||
'internal-background-task': 'gemini-3-flash',
|
||||
};
|
||||
@@ -55,17 +74,50 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
|
||||
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
|
||||
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
|
||||
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3-pro-high', 'gemini-3-pro-low'];
|
||||
const EXTRA_DYNAMIC_MODELS = ['gemini-3-flash', 'gemini-3.1-pro-high', 'gemini-3.1-pro-low'];
|
||||
|
||||
const DYNAMIC_MODEL_FORWARDING_RULES = new Map<string, string>();
|
||||
|
||||
export const MODEL_LIST_CREATED_AT = 1770652800;
|
||||
|
||||
export const MODEL_LIST_OWNER = 'antigravity';
|
||||
|
||||
const GEMINI_MODEL_ALIASES: Record<string, string> = {
|
||||
'gemini-3-pro-image-preview': 'gemini-3-pro-image',
|
||||
'gemini-3-flash-preview': 'gemini-3-flash',
|
||||
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro': 'gemini-3.1-pro-high',
|
||||
};
|
||||
|
||||
export function getSupportedModels(): string[] {
|
||||
return Object.keys(CLAUDE_TO_GEMINI);
|
||||
return [...PUBLIC_SUPPORTED_MODELS];
|
||||
}
|
||||
|
||||
export function getAllDynamicModels(customMapping: Record<string, string> = {}): string[] {
|
||||
export function updateDynamicForwardingRules(oldModel: string, newModel: string): void {
|
||||
if (typeof oldModel !== 'string' || typeof newModel !== 'string') {
|
||||
return;
|
||||
}
|
||||
const normalizedOld = oldModel.trim();
|
||||
const normalizedNew = newModel.trim();
|
||||
if (!normalizedOld || !normalizedNew) {
|
||||
return;
|
||||
}
|
||||
if (!DYNAMIC_MODEL_FORWARDING_RULES.has(normalizedOld)) {
|
||||
logger.info(
|
||||
`[Router] Registered dynamic forwarding rule: ${normalizedOld} -> ${normalizedNew}`,
|
||||
);
|
||||
}
|
||||
DYNAMIC_MODEL_FORWARDING_RULES.set(normalizedOld, normalizedNew);
|
||||
}
|
||||
|
||||
export function getDynamicForwardingTarget(modelId: string): string | undefined {
|
||||
return DYNAMIC_MODEL_FORWARDING_RULES.get(modelId);
|
||||
}
|
||||
|
||||
export function getAllDynamicModels(
|
||||
customMapping: Record<string, string> = {},
|
||||
dynamicModelIds?: Iterable<string>,
|
||||
): string[] {
|
||||
const modelIds = new Set<string>();
|
||||
|
||||
for (const modelId of getSupportedModels()) {
|
||||
@@ -86,10 +138,21 @@ export function getAllDynamicModels(customMapping: Record<string, string> = {}):
|
||||
modelIds.add(modelId);
|
||||
}
|
||||
|
||||
return [...modelIds].filter((id) => !/gemini-[12](\.|$|-)/.test(id)).sort();
|
||||
if (dynamicModelIds) {
|
||||
for (const dynamicModelId of dynamicModelIds) {
|
||||
if (typeof dynamicModelId === 'string' && dynamicModelId.trim() !== '') {
|
||||
modelIds.add(dynamicModelId.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...modelIds].filter((id) => !shouldHideDeprecatedModelFromList(id)).sort();
|
||||
}
|
||||
|
||||
export function mapClaudeModelToGemini(input: string): string {
|
||||
if (!input || typeof input !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const mappedModel = CLAUDE_TO_GEMINI[input];
|
||||
if (mappedModel) {
|
||||
return mappedModel;
|
||||
@@ -98,6 +161,11 @@ export function mapClaudeModelToGemini(input: string): string {
|
||||
return input;
|
||||
}
|
||||
|
||||
export function normalizeGeminiModelAlias(modelId: string): string {
|
||||
const normalizedModelId = modelId.trim().toLowerCase();
|
||||
return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Model Routing Engine
|
||||
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
|
||||
@@ -108,6 +176,14 @@ export function resolveModelRoute(
|
||||
openaiMapping: Record<string, string>,
|
||||
anthropicMapping: Record<string, string>,
|
||||
): string {
|
||||
const dynamicForwarded = getDynamicForwardingTarget(originalModel);
|
||||
if (dynamicForwarded && getSupportedModels().includes(dynamicForwarded)) {
|
||||
logger.info(
|
||||
`[Router] Dynamic deprecated-model forwarding: ${originalModel} -> ${dynamicForwarded}`,
|
||||
);
|
||||
return dynamicForwarded;
|
||||
}
|
||||
|
||||
// 1. Check custom exact mapping (Highest priority)
|
||||
if (customMapping[originalModel]) {
|
||||
logger.info(
|
||||
@@ -194,3 +270,31 @@ export function resolveModelRoute(
|
||||
// 4. Fall through to system default mapping logic
|
||||
return mapClaudeModelToGemini(originalModel);
|
||||
}
|
||||
|
||||
function shouldHideDeprecatedModelFromList(modelId: string): boolean {
|
||||
const normalized = modelId.toLowerCase();
|
||||
|
||||
if (/^gemini-1(\.|$|-)/.test(normalized) || /^gemini-2(\.|$|-)/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === 'gemini-3.1-pro' || normalized === 'gemini-3.1-pro-preview') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isLegacyGeminiPro =
|
||||
/^gemini-3(\.0)?-pro($|-)/.test(normalized) && !normalized.startsWith('gemini-3-pro-image');
|
||||
if (isLegacyGeminiPro) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('claude-sonnet-4-5') ||
|
||||
normalized.includes('claude-opus-4-5') ||
|
||||
normalized.includes('claude-haiku-4-5')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import modelSpecsJson from './model-specs';
|
||||
|
||||
type ModelSpec = {
|
||||
max_output_tokens?: number;
|
||||
thinking_budget?: number;
|
||||
is_thinking?: boolean;
|
||||
};
|
||||
|
||||
type SpecsConfig = {
|
||||
models: Record<string, ModelSpec>;
|
||||
aliases: Record<string, string>;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS = 65535;
|
||||
const DEFAULT_THINKING_BUDGET = 24576;
|
||||
|
||||
const SPECS = modelSpecsJson as SpecsConfig;
|
||||
|
||||
export function resolveModelAlias(modelId: string): string {
|
||||
const normalized = modelId.trim();
|
||||
return SPECS.aliases[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
export function getMaxOutputTokens(modelId: string): number {
|
||||
const resolved = resolveModelAlias(modelId);
|
||||
const fromSpec = SPECS.models[resolved]?.max_output_tokens;
|
||||
if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec > 0) {
|
||||
return Math.floor(fromSpec);
|
||||
}
|
||||
return DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
}
|
||||
|
||||
export function getThinkingBudget(modelId: string): number {
|
||||
const resolved = resolveModelAlias(modelId);
|
||||
const fromSpec = SPECS.models[resolved]?.thinking_budget;
|
||||
if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec >= 0) {
|
||||
return Math.floor(fromSpec);
|
||||
}
|
||||
return DEFAULT_THINKING_BUDGET;
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { QuotaData, LoadProjectResponse, QuotaApiResponse } from './types';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Constants
|
||||
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels';
|
||||
const QUOTA_API_ENDPOINTS = [
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
||||
] as const;
|
||||
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
|
||||
|
||||
@@ -83,15 +87,16 @@ export class QuotaService {
|
||||
const finalProjectId = projectId;
|
||||
|
||||
const client = this.createClient();
|
||||
const payload = { project: finalProjectId };
|
||||
const url = QUOTA_API_URL;
|
||||
const maxRetries = 3;
|
||||
const payload = finalProjectId ? { project: finalProjectId } : {};
|
||||
let lastError: Error | null = null;
|
||||
|
||||
logger.info(`Sending quota request to ${url}`);
|
||||
for (let endpointIndex = 0; endpointIndex < QUOTA_API_ENDPOINTS.length; endpointIndex++) {
|
||||
const endpoint = QUOTA_API_ENDPOINTS[endpointIndex];
|
||||
const hasNextEndpoint = endpointIndex + 1 < QUOTA_API_ENDPOINTS.length;
|
||||
logger.info(`Sending quota request to ${endpoint}`);
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await client.post<QuotaApiResponse>(url, payload, {
|
||||
const response = await client.post<QuotaApiResponse>(endpoint, payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'User-Agent': USER_AGENT,
|
||||
@@ -127,8 +132,14 @@ export class QuotaService {
|
||||
}
|
||||
}
|
||||
|
||||
return { quotaData, projectId: projectId };
|
||||
if (endpointIndex > 0) {
|
||||
logger.info(`Quota API fallback succeeded at endpoint #${endpointIndex + 1}`);
|
||||
}
|
||||
|
||||
return { quotaData, projectId };
|
||||
} catch (error: any) {
|
||||
let shouldFallback = true;
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
let text = '';
|
||||
@@ -147,23 +158,36 @@ export class QuotaService {
|
||||
isForbidden: true,
|
||||
subscriptionTier: subscriptionTier,
|
||||
},
|
||||
projectId: projectId,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
||||
if (hasNextEndpoint && (status === 429 || (typeof status === 'number' && status >= 500))) {
|
||||
logger.warn(
|
||||
`Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
|
||||
);
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warn(`API Error: ${status} - ${text}`);
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
shouldFallback = typeof status !== 'number';
|
||||
} else {
|
||||
logger.warn(`Request Failed: ${error.message} (Attempt ${attempt}/${maxRetries})`);
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error.message}`);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
if (hasNextEndpoint && shouldFallback) {
|
||||
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
} else {
|
||||
throw new Error(`Quota query failed: ${error.message}`);
|
||||
throw lastError ?? new Error(`Quota query failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unknown error in fetchQuota'); // Should not reach here
|
||||
throw lastError ?? new Error('Unknown error in fetchQuota');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"models": {
|
||||
"gemini-3-flash": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gemini-3.1-pro-low": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gemini-3.1-pro-high": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 49152,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gemini-3-pro-image": {
|
||||
"max_output_tokens": 65536,
|
||||
"thinking_budget": 24576,
|
||||
"is_thinking": true
|
||||
},
|
||||
"claude-sonnet-4-6-thinking": {
|
||||
"max_output_tokens": 64000,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"claude-opus-4-6-thinking": {
|
||||
"max_output_tokens": 64000,
|
||||
"thinking_budget": 32768,
|
||||
"is_thinking": true
|
||||
},
|
||||
"gpt-oss-120b-medium": {
|
||||
"max_output_tokens": 32768,
|
||||
"thinking_budget": 0,
|
||||
"is_thinking": false
|
||||
}
|
||||
},
|
||||
"aliases": {
|
||||
"gpt-4o": "gemini-3-flash",
|
||||
"gpt-3.5-turbo": "gemini-3-flash",
|
||||
"gemini-2.0-flash": "gemini-3-flash",
|
||||
"gemini-2.5-flash": "gemini-3-flash",
|
||||
"gemini-3.1-pro": "gemini-3.1-pro-high",
|
||||
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6-thinking",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-6-thinking",
|
||||
"claude-3-5-sonnet": "claude-sonnet-4-6-thinking",
|
||||
"claude-3-7-sonnet": "claude-sonnet-4-6-thinking",
|
||||
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const modelSpecs = {
|
||||
models: {
|
||||
'gemini-3-flash': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gemini-3.1-pro-low': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gemini-3.1-pro-high': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 49152,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gemini-3-pro-image': {
|
||||
max_output_tokens: 65536,
|
||||
thinking_budget: 24576,
|
||||
is_thinking: true,
|
||||
},
|
||||
'claude-sonnet-4-6-thinking': {
|
||||
max_output_tokens: 64000,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'claude-opus-4-6-thinking': {
|
||||
max_output_tokens: 64000,
|
||||
thinking_budget: 32768,
|
||||
is_thinking: true,
|
||||
},
|
||||
'gpt-oss-120b-medium': {
|
||||
max_output_tokens: 32768,
|
||||
thinking_budget: 0,
|
||||
is_thinking: false,
|
||||
},
|
||||
},
|
||||
aliases: {
|
||||
'gpt-4o': 'gemini-3-flash',
|
||||
'gpt-3.5-turbo': 'gemini-3-flash',
|
||||
'gemini-2.0-flash': 'gemini-3-flash',
|
||||
'gemini-2.5-flash': 'gemini-3-flash',
|
||||
'gemini-3.1-pro': 'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
|
||||
'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-5-sonnet': 'claude-sonnet-4-6-thinking',
|
||||
'claude-3-7-sonnet': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-5-thinking': 'claude-opus-4-6-thinking',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default modelSpecs;
|
||||
@@ -64,6 +64,7 @@ export interface ClaudeRequest {
|
||||
export interface ThinkingConfig {
|
||||
type: 'enabled' | string;
|
||||
budget_tokens?: number;
|
||||
effort?: 'low' | 'medium' | 'high' | 'max' | string;
|
||||
}
|
||||
|
||||
export type SystemPrompt = string | SystemBlock[];
|
||||
@@ -194,6 +195,8 @@ export interface ThinkingGeminiConfig {
|
||||
includeThoughts: boolean;
|
||||
/** Thinking token budget */
|
||||
thinkingBudget?: number;
|
||||
/** Thinking level for Claude-native adaptive modes */
|
||||
thinkingLevel?: 'low' | 'medium' | 'high' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,11 +309,22 @@ export interface QuotaData {
|
||||
models: Record<string, ModelQuotaInfo>;
|
||||
isForbidden: boolean;
|
||||
subscriptionTier?: string;
|
||||
model_forwarding_rules?: Record<string, string>;
|
||||
is_forbidden?: boolean;
|
||||
subscription_tier?: string;
|
||||
}
|
||||
|
||||
export interface ModelQuotaInfo {
|
||||
percentage: number;
|
||||
resetTime: string;
|
||||
display_name?: string;
|
||||
supports_images?: boolean;
|
||||
supports_thinking?: boolean;
|
||||
thinking_budget?: number;
|
||||
recommended?: boolean;
|
||||
max_tokens?: number;
|
||||
max_output_tokens?: number;
|
||||
supported_mime_types?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface LoadProjectResponse {
|
||||
@@ -327,7 +341,21 @@ export interface Tier {
|
||||
}
|
||||
|
||||
export interface QuotaApiResponse {
|
||||
models: Record<string, { quotaInfo?: { remainingFraction?: number; resetTime?: string } }>;
|
||||
models: Record<
|
||||
string,
|
||||
{
|
||||
quotaInfo?: { remainingFraction?: number; resetTime?: string };
|
||||
displayName?: string;
|
||||
supportsImages?: boolean;
|
||||
supportsThinking?: boolean;
|
||||
thinkingBudget?: number;
|
||||
recommended?: boolean;
|
||||
maxTokens?: number;
|
||||
maxOutputTokens?: number;
|
||||
supportedMimeTypes?: Record<string, boolean>;
|
||||
}
|
||||
>;
|
||||
deprecatedModelIds?: Record<string, { newModelId?: string }>;
|
||||
}
|
||||
|
||||
// --- Response Models ---
|
||||
|
||||
+146
-3
@@ -118,6 +118,14 @@ i18n
|
||||
restart_note:
|
||||
'Changes to error reporting will take effect after restarting the application.',
|
||||
},
|
||||
notifications: {
|
||||
title: 'Notifications',
|
||||
description: 'Configure desktop alerts for account events.',
|
||||
quotaAlert: 'Low Quota Alerts',
|
||||
quotaAlertDesc: 'Get notified when a model quota drops below the set threshold',
|
||||
quotaThreshold: 'Alert Threshold',
|
||||
quotaThresholdDesc: 'Percentage below which to trigger an alert',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Upstream Proxy',
|
||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||
@@ -132,7 +140,7 @@ i18n
|
||||
claudeKeyword: 'Claude Model (Keyword)',
|
||||
targetGemini: 'Target Gemini Model',
|
||||
addPlaceholderKey: 'e.g. op-3',
|
||||
addPlaceholderValue: 'e.g. gemini-2.5-flash',
|
||||
addPlaceholderValue: 'e.g. gemini-3-flash',
|
||||
noMappings: 'No custom mappings defined.',
|
||||
mapsTo: 'Maps to',
|
||||
default: 'Default',
|
||||
@@ -258,10 +266,13 @@ i18n
|
||||
twoCol: '2 Columns',
|
||||
threeCol: '3 Columns',
|
||||
list: 'List',
|
||||
compact: 'Compact',
|
||||
},
|
||||
authDialog: {
|
||||
title: 'Add Google Account',
|
||||
description: 'To add an account, you need to authorize the application.',
|
||||
oauthClient: 'OAuth Client',
|
||||
oauthClientPlaceholder: 'Select OAuth client',
|
||||
openLogin: 'Open Login Page',
|
||||
authCode: 'Authorization Code',
|
||||
placeholder: 'Paste the code starting with 4/...',
|
||||
@@ -273,6 +284,9 @@ i18n
|
||||
active: 'Active',
|
||||
use: 'Use',
|
||||
rateLimited: 'Rate Limited',
|
||||
validationRiskControlled: 'Risk / Rate Limited',
|
||||
validationOAuthReauthRequired: 'OAuth Re-auth Required',
|
||||
validationRequired: 'Verification Required',
|
||||
left: 'left',
|
||||
used: 'Used',
|
||||
unknown: 'Unknown User',
|
||||
@@ -289,6 +303,13 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 Ready',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: 'Proxy',
|
||||
proxyPlaceholder: 'e.g. http://127.0.0.1:7890',
|
||||
proxySaved: 'Proxy saved',
|
||||
noProxy: 'No proxy',
|
||||
aiCredits: 'AI Credits',
|
||||
creditsExpiry: 'expires {{date}}',
|
||||
modelVisibility: 'Model Visibility',
|
||||
},
|
||||
identity: {
|
||||
title: 'Identity Profile',
|
||||
@@ -357,6 +378,32 @@ i18n
|
||||
clear: 'Clear Selection',
|
||||
confirmDelete: 'Are you sure you want to delete {{count}} accounts?',
|
||||
},
|
||||
sort: {
|
||||
recentlyUsed: 'Recently Used',
|
||||
quotaOverall: 'Overall Quota',
|
||||
quotaClaude: 'Claude Quota',
|
||||
quotaPro3: 'Pro3 Quota',
|
||||
quotaFlash: 'Flash Quota',
|
||||
},
|
||||
exportImport: {
|
||||
export: 'Export',
|
||||
import: 'Import',
|
||||
exportTitle: 'Export Accounts',
|
||||
exportDesc: 'Choose whether to include authentication tokens in the export file.',
|
||||
includeTokens: 'Include tokens (less secure)',
|
||||
stripTokens: 'Strip tokens (safer for sharing)',
|
||||
exportSuccess: 'Accounts exported successfully',
|
||||
importTitle: 'Import Accounts',
|
||||
importDesc: 'Select a previously exported JSON file.',
|
||||
importStrategy: 'Import Strategy',
|
||||
strategyMerge: 'Merge - Update existing, add new',
|
||||
strategyOverwrite: 'Overwrite - Replace all existing data',
|
||||
strategySkip: 'Skip - Only add new accounts',
|
||||
importSuccess: 'Imported {{imported}}, updated {{updated}}, skipped {{skipped}}',
|
||||
importErrors: 'Import completed with {{count}} error(s)',
|
||||
selectFile: 'Select File',
|
||||
importing: 'Importing...',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
title: 'API Proxy',
|
||||
@@ -504,6 +551,14 @@ i18n
|
||||
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||
restart_note: '错误上报设置会在重启应用后生效。',
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
description: '配置账号事件的桌面提醒。',
|
||||
quotaAlert: '低配额提醒',
|
||||
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
|
||||
quotaThreshold: '提醒阈值',
|
||||
quotaThresholdDesc: '触发提醒的百分比阈值',
|
||||
},
|
||||
proxy: {
|
||||
title: '上游代理',
|
||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||
@@ -518,7 +573,7 @@ i18n
|
||||
claudeKeyword: 'Claude 模型 (关键词)',
|
||||
targetGemini: '目标 Gemini 模型',
|
||||
addPlaceholderKey: '例如 op-3',
|
||||
addPlaceholderValue: '例如 gemini-2.5-flash',
|
||||
addPlaceholderValue: '例如 gemini-3-flash',
|
||||
noMappings: '暂无自定义映射。',
|
||||
mapsTo: '映射到',
|
||||
default: '默认',
|
||||
@@ -642,10 +697,13 @@ i18n
|
||||
twoCol: '2 列',
|
||||
threeCol: '3 列',
|
||||
list: '列表',
|
||||
compact: '紧凑',
|
||||
},
|
||||
authDialog: {
|
||||
title: '添加 Google 账号',
|
||||
description: '添加账号需要进行应用授权。',
|
||||
oauthClient: 'OAuth 客户端',
|
||||
oauthClientPlaceholder: '请选择 OAuth 客户端',
|
||||
openLogin: '打开登录页面',
|
||||
authCode: '授权码',
|
||||
placeholder: '粘贴以 4/ 开头的代码...',
|
||||
@@ -656,6 +714,9 @@ i18n
|
||||
active: '活跃',
|
||||
use: '使用',
|
||||
rateLimited: '受限',
|
||||
validationRiskControlled: '风控 / 限流',
|
||||
validationOAuthReauthRequired: '需要重新 OAuth 授权',
|
||||
validationRequired: '需要校验',
|
||||
left: '剩余',
|
||||
used: '已用',
|
||||
unknown: '未知用户',
|
||||
@@ -672,6 +733,13 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 就绪',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: '代理',
|
||||
proxyPlaceholder: '例如 http://127.0.0.1:7890',
|
||||
proxySaved: '代理已保存',
|
||||
noProxy: '无代理',
|
||||
aiCredits: 'AI 积分',
|
||||
creditsExpiry: '{{date}} 过期',
|
||||
modelVisibility: '模型可见性',
|
||||
},
|
||||
identity: {
|
||||
title: '身份配置',
|
||||
@@ -740,6 +808,32 @@ i18n
|
||||
clear: '取消选择',
|
||||
confirmDelete: '确定要删除选中的 {{count}} 个账号吗?',
|
||||
},
|
||||
sort: {
|
||||
recentlyUsed: '最近使用',
|
||||
quotaOverall: '总体配额',
|
||||
quotaClaude: 'Claude 配额',
|
||||
quotaPro3: 'Pro3 配额',
|
||||
quotaFlash: 'Flash 配额',
|
||||
},
|
||||
exportImport: {
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
exportTitle: '导出账号',
|
||||
exportDesc: '选择是否在导出文件中包含认证令牌。',
|
||||
includeTokens: '包含令牌(安全性较低)',
|
||||
stripTokens: '移除令牌(适合分享)',
|
||||
exportSuccess: '账号导出成功',
|
||||
importTitle: '导入账号',
|
||||
importDesc: '选择之前导出的 JSON 文件。',
|
||||
importStrategy: '导入策略',
|
||||
strategyMerge: '合并 - 更新已有,添加新账号',
|
||||
strategyOverwrite: '覆盖 - 替换所有已有数据',
|
||||
strategySkip: '跳过 - 仅添加新账号',
|
||||
importSuccess: '导入 {{imported}},更新 {{updated}},跳过 {{skipped}}',
|
||||
importErrors: '导入完成,但有 {{count}} 个错误',
|
||||
selectFile: '选择文件',
|
||||
importing: '导入中...',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
title: 'API 反代',
|
||||
@@ -889,6 +983,15 @@ i18n
|
||||
restart_note:
|
||||
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||
},
|
||||
notifications: {
|
||||
title: 'Уведомления',
|
||||
description: 'Настройте уведомления о событиях аккаунтов.',
|
||||
quotaAlert: 'Уведомления о низкой квоте',
|
||||
quotaAlertDesc:
|
||||
'Получайте уведомления, когда квота модели падает ниже установленного порога',
|
||||
quotaThreshold: 'Порог уведомления',
|
||||
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
|
||||
},
|
||||
proxy: {
|
||||
title: 'Верхнеуровневый прокси',
|
||||
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||
@@ -903,7 +1006,7 @@ i18n
|
||||
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
||||
targetGemini: 'Целевая модель Gemini',
|
||||
addPlaceholderKey: 'например, op-3',
|
||||
addPlaceholderValue: 'например, gemini-2.5-flash',
|
||||
addPlaceholderValue: 'например, gemini-3-flash',
|
||||
noMappings: 'Нет пользовательских сопоставлений.',
|
||||
mapsTo: 'Мапится в',
|
||||
default: 'По умолчанию',
|
||||
@@ -1028,10 +1131,13 @@ i18n
|
||||
twoCol: '2 колонки',
|
||||
threeCol: '3 колонки',
|
||||
list: 'Список',
|
||||
compact: 'Компактный',
|
||||
},
|
||||
authDialog: {
|
||||
title: 'Добавить Google Аккаунт',
|
||||
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
||||
oauthClient: 'OAuth клиент',
|
||||
oauthClientPlaceholder: 'Выберите OAuth клиент',
|
||||
openLogin: 'Открыть страницу входа',
|
||||
authCode: 'Код авторизации',
|
||||
placeholder: 'Вставьте код, начинающийся с 4/...',
|
||||
@@ -1043,6 +1149,9 @@ i18n
|
||||
active: 'Активен',
|
||||
use: 'Использовать',
|
||||
rateLimited: 'Лимит исчерпан',
|
||||
validationRiskControlled: 'Риск / Ограничение',
|
||||
validationOAuthReauthRequired: 'Требуется повторный OAuth',
|
||||
validationRequired: 'Требуется проверка',
|
||||
left: 'ост.',
|
||||
used: 'Исп.',
|
||||
unknown: 'Неизвестный',
|
||||
@@ -1059,6 +1168,13 @@ i18n
|
||||
gemini3Ready: 'Gemini 3 готов',
|
||||
groupGoogleGemini: 'Google Gemini',
|
||||
groupAnthropicClaude: 'Anthropic Claude',
|
||||
proxy: 'Прокси',
|
||||
proxyPlaceholder: 'напр. http://127.0.0.1:7890',
|
||||
proxySaved: 'Прокси сохранён',
|
||||
noProxy: 'Без прокси',
|
||||
aiCredits: 'AI Кредиты',
|
||||
creditsExpiry: 'истекает {{date}}',
|
||||
modelVisibility: 'Видимость моделей',
|
||||
},
|
||||
identity: {
|
||||
title: 'Профиль идентичности',
|
||||
@@ -1127,6 +1243,33 @@ i18n
|
||||
clear: 'Снять выделение',
|
||||
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
||||
},
|
||||
sort: {
|
||||
recentlyUsed: 'Недавние',
|
||||
quotaOverall: 'Общая квота',
|
||||
quotaClaude: 'Квота Claude',
|
||||
quotaPro3: 'Квота Pro3',
|
||||
quotaFlash: 'Квота Flash',
|
||||
},
|
||||
exportImport: {
|
||||
export: 'Экспорт',
|
||||
import: 'Импорт',
|
||||
exportTitle: 'Экспорт аккаунтов',
|
||||
exportDesc: 'Выберите, включать ли токены аутентификации в файл экспорта.',
|
||||
includeTokens: 'Включить токены (менее безопасно)',
|
||||
stripTokens: 'Удалить токены (безопаснее для обмена)',
|
||||
exportSuccess: 'Аккаунты успешно экспортированы',
|
||||
importTitle: 'Импорт аккаунтов',
|
||||
importDesc: 'Выберите ранее экспортированный JSON файл.',
|
||||
importStrategy: 'Стратегия импорта',
|
||||
strategyMerge: 'Объединить - Обновить существующие, добавить новые',
|
||||
strategyOverwrite: 'Перезаписать - Заменить все существующие данные',
|
||||
strategySkip: 'Пропустить - Добавить только новые аккаунты',
|
||||
importSuccess:
|
||||
'Импортировано {{imported}}, обновлено {{updated}}, пропущено {{skipped}}',
|
||||
importErrors: 'Импорт завершён с {{count}} ошибкой(ами)',
|
||||
selectFile: 'Выбрать файл',
|
||||
importing: 'Импорт...',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
title: 'API Прокси',
|
||||
|
||||
+54
@@ -4,6 +4,7 @@ import type { MessageBoxOptions } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import squirrelStartup from 'electron-squirrel-startup';
|
||||
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
|
||||
import { ipcMain } from 'electron/main';
|
||||
import { ipcContext } from '@/ipc/context';
|
||||
@@ -51,12 +52,53 @@ ipcMain.on(IPC_CHANNELS.CHANGE_LANGUAGE, (event, lang) => {
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing');
|
||||
logger.info('Applied Linux GPU-safe Chromium switches for Antigravity Manager startup');
|
||||
}
|
||||
|
||||
if (squirrelStartup) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
const debugHttpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
|
||||
const debugHttpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
|
||||
const debugNoProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
|
||||
const debugProxyServer =
|
||||
process.env.ELECTRON_PROXY_SERVER?.trim() || debugHttpsProxy || debugHttpProxy;
|
||||
const debugProxyBypassList =
|
||||
process.env.ELECTRON_PROXY_BYPASS_LIST?.trim() || '<local>;localhost;127.0.0.1;::1';
|
||||
|
||||
function configureDebugProxy() {
|
||||
if (debugHttpProxy || debugHttpsProxy) {
|
||||
setGlobalDispatcher(
|
||||
new EnvHttpProxyAgent({
|
||||
httpProxy: debugHttpProxy,
|
||||
httpsProxy: debugHttpsProxy,
|
||||
noProxy: debugNoProxy,
|
||||
}),
|
||||
);
|
||||
logger.info(
|
||||
`[Debug Proxy] Node fetch proxy enabled (http: ${debugHttpProxy ?? 'none'}, https: ${debugHttpsProxy ?? 'none'}, no_proxy: ${debugNoProxy ?? 'none'})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!debugProxyServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route Chromium traffic through the local debug proxy while keeping local dev servers direct.
|
||||
app.commandLine.appendSwitch('proxy-server', debugProxyServer);
|
||||
app.commandLine.appendSwitch('proxy-bypass-list', debugProxyBypassList);
|
||||
logger.info(
|
||||
`[Debug Proxy] Electron proxy enabled: ${debugProxyServer} (bypass: ${debugProxyBypassList})`,
|
||||
);
|
||||
}
|
||||
|
||||
configureDebugProxy();
|
||||
|
||||
// Keep a global reference of the window object, if you don't, the window will
|
||||
// be closed automatically when the JavaScript object is garbage collected.
|
||||
@@ -122,10 +164,17 @@ function showWindowsInstallNoticeIfNeeded() {
|
||||
});
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
|
||||
if (isDev) {
|
||||
app.setName('Antigravity Manager Dev');
|
||||
}
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
logger.info('Second instance detected, focusing existing window');
|
||||
@@ -418,6 +467,11 @@ app
|
||||
if (enabled) {
|
||||
logger.info('Startup: Auto-Switch enabled, starting monitor...');
|
||||
CloudMonitorService.start();
|
||||
} else {
|
||||
logger.info(
|
||||
'Startup: Auto-Switch disabled, running one-time quota and AI credits sync...',
|
||||
);
|
||||
await CloudMonitorService.poll();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Startup: Failed to initialize services', e);
|
||||
|
||||
+104
-86
@@ -6,7 +6,7 @@ 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 { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { ProxyConfig } from '@/types/config';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -43,6 +43,59 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
type ProxyProtocol = 'openai' | 'anthropic';
|
||||
|
||||
interface ExampleModel {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
const EXAMPLE_MODELS: ExampleModel[] = [
|
||||
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash', icon: <Zap size={14} /> },
|
||||
{ id: 'gemini-3.1-pro-low', name: 'Gemini 3.1 Pro (Low)', icon: <Cpu size={14} /> },
|
||||
{ id: 'gemini-3.1-pro-high', name: 'Gemini 3.1 Pro (High)', icon: <Cpu size={14} /> },
|
||||
{
|
||||
id: 'claude-sonnet-4-6-thinking',
|
||||
name: 'Claude Sonnet 4.6 (Thinking)',
|
||||
icon: <Sparkles size={14} />,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-6-thinking',
|
||||
name: 'Claude Opus 4.6 (Thinking)',
|
||||
icon: <BrainCircuit size={14} />,
|
||||
},
|
||||
];
|
||||
|
||||
const ANTHROPIC_ROUTE_OPTIONS = [
|
||||
'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-thinking',
|
||||
'gemini-3-flash',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3.1-pro-high',
|
||||
] as const;
|
||||
|
||||
const DEFAULT_ANTHROPIC_MAPPING: Record<string, string> = {
|
||||
'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking',
|
||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-6-thinking',
|
||||
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
|
||||
opus: 'claude-opus-4-6-thinking',
|
||||
};
|
||||
|
||||
function resolveAnthropicMappingValue(
|
||||
anthropicMapping: Record<string, string>,
|
||||
keys: string[],
|
||||
fallback: string,
|
||||
): string {
|
||||
for (const key of keys) {
|
||||
const value = anthropicMapping[key];
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function ProxyPage() {
|
||||
const { t } = useTranslation();
|
||||
const { config, isLoading, saveConfig } = useAppConfig();
|
||||
@@ -97,14 +150,14 @@ function ProxyPage() {
|
||||
} else {
|
||||
setProxyConfig(config.proxy);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// If status check fails, just use config value
|
||||
setProxyConfig(config.proxy);
|
||||
}
|
||||
};
|
||||
syncServerStatus();
|
||||
}
|
||||
}, [config]);
|
||||
}, [config, saveConfig]);
|
||||
|
||||
// Helper to update proxyConfig and auto-save
|
||||
const updateProxyConfig = async (newProxyConfig: ProxyConfig) => {
|
||||
@@ -115,24 +168,27 @@ function ProxyPage() {
|
||||
};
|
||||
|
||||
// ===== Usage Examples State =====
|
||||
const [selectedProtocol, setSelectedProtocol] = useState<'openai' | 'anthropic'>('openai');
|
||||
const [activeModelTab, setActiveModelTab] = useState('gemini-2.5-flash');
|
||||
const [selectedProtocol, setSelectedProtocol] = useState<ProxyProtocol>('openai');
|
||||
const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high');
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
// Models list for examples
|
||||
const models = [
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', icon: <Zap size={14} /> },
|
||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', icon: <Cpu size={14} /> },
|
||||
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash', icon: <Zap size={14} /> },
|
||||
{ id: 'gemini-3-pro-high', name: 'Gemini 3 Pro (High)', icon: <Cpu size={14} /> },
|
||||
{ id: 'claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5', icon: <Sparkles size={14} /> },
|
||||
{ id: 'claude-opus-4-5-thinking', name: 'Claude Opus 4.5', icon: <BrainCircuit size={14} /> },
|
||||
];
|
||||
|
||||
// Computed values for examples
|
||||
const apiKey = proxyConfig?.api_key || 'YOUR_API_KEY';
|
||||
const baseUrl = `http://localhost:${proxyConfig?.port || 8045}`;
|
||||
|
||||
const updateAnthropicMapping = (mappingPatch: Record<string, string>) => {
|
||||
if (!proxyConfig) {
|
||||
return;
|
||||
}
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
...proxyConfig.anthropic_mapping,
|
||||
...mappingPatch,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, type: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(type);
|
||||
@@ -415,30 +471,28 @@ print(response.choices[0].message.content)`;
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{/* Sonnet 4.5 Card */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* Sonnet 4.6 Card */}
|
||||
<div className="flex flex-col rounded-lg border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100/50 p-4 dark:border-blue-800/50 dark:from-blue-950/30 dark:to-blue-900/20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Claude Sonnet 4.5
|
||||
Claude Sonnet 4.6 (Thinking)
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{t('proxy.mapping.maps_to', 'Maps to')}
|
||||
</p>
|
||||
<Select
|
||||
value={
|
||||
proxyConfig.anthropic_mapping['claude-sonnet-4-5-20250929'] ||
|
||||
'claude-sonnet-4-5-thinking'
|
||||
}
|
||||
value={resolveAnthropicMappingValue(
|
||||
proxyConfig.anthropic_mapping,
|
||||
['claude-sonnet-4-6-20260219', 'claude-sonnet-4-5-20250929'],
|
||||
'claude-sonnet-4-6-thinking',
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
...proxyConfig.anthropic_mapping,
|
||||
'claude-sonnet-4-5-20250929': value,
|
||||
},
|
||||
updateAnthropicMapping({
|
||||
'claude-sonnet-4-6-20260219': value,
|
||||
'claude-sonnet-4-5-20250929': value,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -446,34 +500,36 @@ print(response.choices[0].message.content)`;
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude-sonnet-4-5-thinking">
|
||||
claude-sonnet-4-5-thinking
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini-2.5-flash">gemini-2.5-flash</SelectItem>
|
||||
<SelectItem value="gemini-2.5-pro">gemini-2.5-pro</SelectItem>
|
||||
<SelectItem value="gemini-3-flash">gemini-3-flash</SelectItem>
|
||||
<SelectItem value="gemini-3-pro-high">gemini-3-pro-high</SelectItem>
|
||||
{ANTHROPIC_ROUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Opus 4.5 Card */}
|
||||
{/* Opus 4.6 Card */}
|
||||
<div className="flex flex-col rounded-lg border-2 border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100/50 p-4 dark:border-purple-800/50 dark:from-purple-950/30 dark:to-purple-900/20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-purple-500"></div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Claude Opus 4.5
|
||||
Claude Opus 4.6 (Thinking)
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{t('proxy.mapping.maps_to', 'Maps to')}
|
||||
</p>
|
||||
<Select
|
||||
value={proxyConfig.anthropic_mapping['opus'] || 'claude-opus-4-5-thinking'}
|
||||
value={resolveAnthropicMappingValue(
|
||||
proxyConfig.anthropic_mapping,
|
||||
['claude-opus-4-6-20260201', 'opus'],
|
||||
'claude-opus-4-6-thinking',
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: { ...proxyConfig.anthropic_mapping, opus: value },
|
||||
updateAnthropicMapping({
|
||||
'claude-opus-4-6-20260201': value,
|
||||
opus: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -481,45 +537,11 @@ print(response.choices[0].message.content)`;
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude-opus-4-5-thinking">claude-opus-4-5-thinking</SelectItem>
|
||||
<SelectItem value="gemini-2.5-pro">gemini-2.5-pro</SelectItem>
|
||||
<SelectItem value="gemini-3-pro-high">gemini-3-pro-high</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Haiku 4.5 Card */}
|
||||
<div className="flex flex-col rounded-lg border-2 border-green-200 bg-gradient-to-br from-green-50 to-green-100/50 p-4 dark:border-green-800/50 dark:from-green-950/30 dark:to-green-900/20">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500"></div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
Claude Haiku 4.5
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{t('proxy.mapping.maps_to', 'Maps to')}
|
||||
</p>
|
||||
<Select
|
||||
value={
|
||||
proxyConfig.anthropic_mapping['claude-haiku-4-5-20251001'] || 'gemini-2.5-flash'
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
...proxyConfig.anthropic_mapping,
|
||||
'claude-haiku-4-5-20251001': value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-2.5-flash">gemini-2.5-flash</SelectItem>
|
||||
<SelectItem value="gemini-2.5-flash-lite">gemini-2.5-flash-lite</SelectItem>
|
||||
<SelectItem value="gemini-3-flash">gemini-3-flash</SelectItem>
|
||||
{ANTHROPIC_ROUTE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -532,11 +554,7 @@ print(response.choices[0].message.content)`;
|
||||
onClick={() =>
|
||||
updateProxyConfig({
|
||||
...proxyConfig,
|
||||
anthropic_mapping: {
|
||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking',
|
||||
opus: 'claude-opus-4-5-thinking',
|
||||
'claude-haiku-4-5-20251001': 'gemini-2.5-flash',
|
||||
},
|
||||
anthropic_mapping: { ...DEFAULT_ANTHROPIC_MAPPING },
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -607,7 +625,7 @@ print(response.choices[0].message.content)`;
|
||||
|
||||
{/* Model Tabs */}
|
||||
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||
{models.map((model) => (
|
||||
{EXAMPLE_MODELS.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => setActiveModelTab(model.id)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getAppVersion, getPlatform } from '@/actions/app';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { setAppLanguage } from '@/actions/language';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, FolderOpen } from 'lucide-react';
|
||||
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -27,6 +28,7 @@ function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { config, isLoading, saveConfig } = useAppConfig();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Local state for configuration editing
|
||||
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
||||
@@ -297,6 +299,86 @@ function SettingsPage() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.notifications.title', 'Notifications')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'settings.notifications.description',
|
||||
'Configure desktop alerts for account events.',
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaAlert', 'Low Quota Alerts')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t(
|
||||
'settings.notifications.quotaAlertDesc',
|
||||
'Get notified when a model quota drops below the set threshold',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.quota_alert_enabled || false}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_enabled: checked });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: 'Failed to save notification settings',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.notifications.quotaThreshold', 'Alert Threshold')}</Label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t(
|
||||
'settings.notifications.quotaThresholdDesc',
|
||||
'Percentage below which to trigger an alert',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config?.quota_alert_threshold ?? 20}
|
||||
onChange={async (e) => {
|
||||
const rawValue = e.target.value;
|
||||
const parsed = parseInt(rawValue, 10);
|
||||
if (isNaN(parsed) || parsed < 0 || parsed > 100) return;
|
||||
|
||||
if (config) {
|
||||
try {
|
||||
await saveConfig({ ...config, quota_alert_threshold: parsed });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: 'Failed to save threshold setting',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* --- MODELS TAB --- */}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { isNil } from 'lodash-es';
|
||||
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
||||
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
|
||||
import { getServerConfig } from '../../../server-config';
|
||||
import { resolveRequestUserAgent } from '../request-user-agent';
|
||||
import { UpstreamRequestError } from './upstream-error';
|
||||
|
||||
@Injectable()
|
||||
export class GeminiClient {
|
||||
private readonly logger = new Logger(GeminiClient.name);
|
||||
private readonly defaultRequestUserAgent = 'antigravity/1.11.9 windows/amd64';
|
||||
// Default to v1beta for most features
|
||||
private readonly baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
||||
private readonly defaultInternalBaseUrls = [
|
||||
@@ -23,7 +24,7 @@ export class GeminiClient {
|
||||
upstreamProxyUrl?: string,
|
||||
): Promise<NodeJS.ReadableStream> {
|
||||
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
|
||||
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
||||
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
|
||||
|
||||
try {
|
||||
const response = await axios.post(url, content, {
|
||||
@@ -39,7 +40,14 @@ export class GeminiClient {
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
this.logger.error(`Gemini stream request failed: ${error.message}`);
|
||||
throw new Error(error.response?.data?.error?.message || error.message);
|
||||
throw new UpstreamRequestError({
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
status: error.response?.status,
|
||||
headers: {
|
||||
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
|
||||
},
|
||||
body: this.describeAxiosErrorData(error.response?.data),
|
||||
});
|
||||
}
|
||||
this.throwAsCleanError(error);
|
||||
}
|
||||
@@ -52,7 +60,7 @@ export class GeminiClient {
|
||||
upstreamProxyUrl?: string,
|
||||
): Promise<GeminiResponse> {
|
||||
const url = `${this.baseUrl}/models/${model}:generateContent`;
|
||||
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
||||
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
|
||||
|
||||
try {
|
||||
const response = await axios.post<GeminiResponse>(url, content, {
|
||||
@@ -69,7 +77,14 @@ export class GeminiClient {
|
||||
this.logger.error(
|
||||
`Gemini request failed: ${error.message} - ${this.safeStringify(error.response?.data)}`,
|
||||
);
|
||||
throw new Error(error.response?.data?.error?.message || error.message);
|
||||
throw new UpstreamRequestError({
|
||||
message: error.response?.data?.error?.message || error.message,
|
||||
status: error.response?.status,
|
||||
headers: {
|
||||
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
|
||||
},
|
||||
body: this.describeAxiosErrorData(error.response?.data),
|
||||
});
|
||||
}
|
||||
this.throwAsCleanError(error);
|
||||
}
|
||||
@@ -83,7 +98,7 @@ export class GeminiClient {
|
||||
upstreamProxyUrl?: string,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<NodeJS.ReadableStream> {
|
||||
const response = await this.requestWithEndpointFailover<NodeJS.ReadableStream>(
|
||||
const response = await this.executeRequestWithEndpointFailover<NodeJS.ReadableStream>(
|
||||
':streamGenerateContent?alt=sse',
|
||||
body,
|
||||
accessToken,
|
||||
@@ -104,7 +119,7 @@ export class GeminiClient {
|
||||
upstreamProxyUrl?: string,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<GeminiResponse> {
|
||||
const response = await this.requestWithEndpointFailover<
|
||||
const response = await this.executeRequestWithEndpointFailover<
|
||||
GeminiResponse | { response: GeminiResponse }
|
||||
>(
|
||||
':generateContent',
|
||||
@@ -143,7 +158,7 @@ export class GeminiClient {
|
||||
return Math.max(1, timeoutSeconds) * 1000;
|
||||
}
|
||||
|
||||
private shouldSwitchEndpointOnError(error: unknown): boolean {
|
||||
private shouldFailoverToNextEndpoint(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return false;
|
||||
}
|
||||
@@ -162,7 +177,7 @@ export class GeminiClient {
|
||||
return status === 408 || status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
private async requestWithEndpointFailover<T>(
|
||||
private async executeRequestWithEndpointFailover<T>(
|
||||
path: string,
|
||||
body: GeminiInternalRequest,
|
||||
accessToken: string,
|
||||
@@ -173,8 +188,8 @@ export class GeminiClient {
|
||||
): Promise<AxiosResponse<T>> {
|
||||
const baseUrls = this.getInternalBaseUrls();
|
||||
const timeout = this.getInternalTimeoutMs();
|
||||
const requestUserAgent = process.env.PROXY_REQUEST_USER_AGENT ?? this.defaultRequestUserAgent;
|
||||
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let index = 0; index < baseUrls.length; index++) {
|
||||
@@ -197,8 +212,8 @@ export class GeminiClient {
|
||||
lastError = error;
|
||||
const hasNextEndpoint = index < baseUrls.length - 1;
|
||||
|
||||
if (!hasNextEndpoint || !this.shouldSwitchEndpointOnError(error)) {
|
||||
await this.handleAxiosError(error, operation);
|
||||
if (!hasNextEndpoint || !this.shouldFailoverToNextEndpoint(error)) {
|
||||
await this.throwUpstreamRequestError(error, operation);
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
@@ -209,11 +224,11 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
|
||||
await this.handleAxiosError(lastError, operation);
|
||||
await this.throwUpstreamRequestError(lastError, operation);
|
||||
throw new Error(`[${operation}] unexpected control flow after upstream error handling`);
|
||||
}
|
||||
|
||||
private async handleAxiosError(error: unknown, operation: string): Promise<never> {
|
||||
private async throwUpstreamRequestError(error: unknown, operation: string): Promise<never> {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const responseData = error.response?.data;
|
||||
const upstreamMessage = await this.extractAxiosErrorMessage(responseData);
|
||||
@@ -222,7 +237,14 @@ export class GeminiClient {
|
||||
responseData,
|
||||
)}`,
|
||||
);
|
||||
throw new Error(upstreamMessage || error.message);
|
||||
throw new UpstreamRequestError({
|
||||
message: upstreamMessage || error.message,
|
||||
status: error.response?.status,
|
||||
headers: {
|
||||
retryAfter: this.extractRetryAfterHeader(error.response?.headers),
|
||||
},
|
||||
body: this.describeAxiosErrorData(responseData),
|
||||
});
|
||||
}
|
||||
this.throwAsCleanError(error);
|
||||
}
|
||||
@@ -346,7 +368,9 @@ export class GeminiClient {
|
||||
return this.safeStringify(responseData);
|
||||
}
|
||||
|
||||
private resolveAxiosProxy(upstreamProxyUrl?: string): AxiosProxyConfig | false | undefined {
|
||||
private resolveUpstreamAxiosProxy(
|
||||
upstreamProxyUrl?: string,
|
||||
): AxiosProxyConfig | false | undefined {
|
||||
const config = getServerConfig();
|
||||
const configuredProxyUrl =
|
||||
upstreamProxyUrl ||
|
||||
@@ -382,6 +406,24 @@ export class GeminiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private extractRetryAfterHeader(headers: unknown): string | undefined {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const retryAfter = (headers as Record<string, unknown>)['retry-after'];
|
||||
if (typeof retryAfter === 'string' && retryAfter.trim() !== '') {
|
||||
return retryAfter.trim();
|
||||
}
|
||||
if (Array.isArray(retryAfter) && retryAfter.length > 0) {
|
||||
const first = retryAfter[0];
|
||||
if (typeof first === 'string' && first.trim() !== '') {
|
||||
return first.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private throwAsCleanError(error: unknown): never {
|
||||
// Re-throw as clean Error to avoid circular reference issues.
|
||||
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface UpstreamErrorHeaders {
|
||||
retryAfter?: string;
|
||||
}
|
||||
|
||||
export class UpstreamRequestError extends Error {
|
||||
readonly status?: number;
|
||||
readonly headers?: UpstreamErrorHeaders;
|
||||
readonly body?: string;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
status?: number;
|
||||
headers?: UpstreamErrorHeaders;
|
||||
body?: string;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = 'UpstreamRequestError';
|
||||
|
||||
if (params.status && typeof params.status !== 'number') {
|
||||
throw new TypeError('status must be a number');
|
||||
}
|
||||
if (params.headers && typeof params.headers !== 'object') {
|
||||
throw new TypeError('headers must be an object');
|
||||
}
|
||||
|
||||
this.status = params.status;
|
||||
this.headers = params.headers;
|
||||
|
||||
// Sanitize and limit body size
|
||||
if (typeof params.body === 'string') {
|
||||
const sanitized = params.body.replace(/<[^>]*>?/gm, '');
|
||||
this.body = sanitized.substring(0, 1000);
|
||||
} else {
|
||||
this.body = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -18,6 +19,7 @@ import { ProxyService } from './proxy.service';
|
||||
import { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
|
||||
import { getServerConfig } from '../../server-config';
|
||||
import { getAllDynamicModels } from '../../../lib/antigravity/ModelMapping';
|
||||
import { TokenManagerService } from './token-manager.service';
|
||||
|
||||
type GeminiModelMetadata = {
|
||||
name: string;
|
||||
@@ -35,7 +37,10 @@ type GeminiModelMetadata = {
|
||||
@Controller('v1beta')
|
||||
@UseGuards(ProxyGuard)
|
||||
export class GeminiController {
|
||||
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) {}
|
||||
constructor(
|
||||
@Inject(ProxyService) private readonly proxyService: ProxyService,
|
||||
@Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService,
|
||||
) {}
|
||||
|
||||
@Get('models')
|
||||
listModels(@Res() res: FastifyReply) {
|
||||
@@ -70,7 +75,7 @@ export class GeminiController {
|
||||
@Body() body: GeminiRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const parsed = this.parseCombinedModelAction(modelAction);
|
||||
const parsed = this.parseModelActionToken(modelAction);
|
||||
if (!parsed) {
|
||||
res.status(HttpStatus.BAD_REQUEST).send({
|
||||
error: {
|
||||
@@ -82,7 +87,7 @@ export class GeminiController {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dispatchAction(parsed.model, parsed.action, body, res);
|
||||
await this.handleModelActionDispatch(parsed.model, parsed.action, body, res);
|
||||
}
|
||||
|
||||
@Post('models/:model/countTokens')
|
||||
@@ -91,10 +96,10 @@ export class GeminiController {
|
||||
@Body() body: GeminiRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
await this.dispatchAction(`models/${model}`, 'countTokens', body, res);
|
||||
await this.handleModelActionDispatch(`models/${model}`, 'countTokens', body, res);
|
||||
}
|
||||
|
||||
private async dispatchAction(
|
||||
private async handleModelActionDispatch(
|
||||
model: string,
|
||||
action: string,
|
||||
body: GeminiRequest,
|
||||
@@ -111,14 +116,14 @@ export class GeminiController {
|
||||
if (action === 'streamGenerateContent') {
|
||||
const stream = await this.proxyService.handleGeminiStreamGenerateContent(model, body);
|
||||
if (stream instanceof Observable) {
|
||||
this.sendObservableSse(res, stream);
|
||||
this.writeObservableSseResponse(res, stream);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'generateContent') {
|
||||
const result = await this.proxyService.handleGeminiGenerateContent(model, body);
|
||||
res.status(HttpStatus.OK).send(this.normalizeGeminiGenerateResponse(result));
|
||||
res.status(HttpStatus.OK).send(this.buildNormalizedGeminiGenerateResponse(result));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,7 +146,7 @@ export class GeminiController {
|
||||
}
|
||||
}
|
||||
|
||||
private parseCombinedModelAction(modelAction: string): {
|
||||
private parseModelActionToken(modelAction: string): {
|
||||
model: string;
|
||||
action: string;
|
||||
} | null {
|
||||
@@ -165,7 +170,10 @@ export class GeminiController {
|
||||
|
||||
private buildGeminiModelList(): GeminiModelMetadata[] {
|
||||
const config = getServerConfig();
|
||||
const dynamicModelIds = getAllDynamicModels(config?.custom_mapping ?? {});
|
||||
const dynamicModelIds = getAllDynamicModels(
|
||||
config?.custom_mapping ?? {},
|
||||
this.tokenManager?.getAllCollectedModels(),
|
||||
);
|
||||
|
||||
return dynamicModelIds.map((id) => this.toGeminiModelMetadata(`models/${id}`));
|
||||
}
|
||||
@@ -186,7 +194,7 @@ export class GeminiController {
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
|
||||
private buildNormalizedGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
|
||||
const candidates = (response.candidates ?? []).map((candidate, index) => ({
|
||||
content: candidate.content,
|
||||
finishReason: candidate.finishReason,
|
||||
@@ -235,7 +243,7 @@ export class GeminiController {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private sendObservableSse(res: FastifyReply, stream: Observable<unknown>): void {
|
||||
private writeObservableSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
|
||||
if (
|
||||
!res.raw ||
|
||||
typeof res.raw.writeHead !== 'function' ||
|
||||
@@ -248,7 +256,7 @@ export class GeminiController {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasHijack(res)) {
|
||||
if (this.supportsReplyHijack(res)) {
|
||||
res.hijack();
|
||||
}
|
||||
|
||||
@@ -293,7 +301,7 @@ export class GeminiController {
|
||||
});
|
||||
}
|
||||
|
||||
private hasHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } {
|
||||
private supportsReplyHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } {
|
||||
return typeof (reply as { hijack?: unknown }).hijack === 'function';
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,13 @@ export interface OpenAIContentPart {
|
||||
}
|
||||
|
||||
export interface OpenAITool {
|
||||
type: 'function';
|
||||
function: {
|
||||
type: string;
|
||||
function?: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OpenAIToolCall {
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
Inject,
|
||||
Req,
|
||||
Logger,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { isNil, isPlainObject, isString } from 'lodash-es';
|
||||
import { ProxyService } from './proxy.service';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
@@ -29,20 +30,27 @@ import {
|
||||
MODEL_LIST_OWNER,
|
||||
} from '../../../lib/antigravity/ModelMapping';
|
||||
import { getServerConfig } from '../../server-config';
|
||||
import { TokenManagerService } from './token-manager.service';
|
||||
|
||||
@Controller('v1')
|
||||
@UseGuards(ProxyGuard)
|
||||
export class ProxyController {
|
||||
private readonly logger = new Logger(ProxyController.name);
|
||||
|
||||
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) {}
|
||||
constructor(
|
||||
@Inject(ProxyService) private readonly proxyService: ProxyService,
|
||||
@Optional() @Inject(TokenManagerService) private readonly tokenManager?: TokenManagerService,
|
||||
) {}
|
||||
|
||||
@Get('models')
|
||||
listModels(@Res() res: FastifyReply) {
|
||||
try {
|
||||
const config = getServerConfig();
|
||||
const customMapping = config?.custom_mapping ?? {};
|
||||
const modelIds = getAllDynamicModels(customMapping);
|
||||
const modelIds = getAllDynamicModels(
|
||||
customMapping,
|
||||
this.tokenManager?.getAllCollectedModels(),
|
||||
);
|
||||
|
||||
const data = modelIds.map((id) => ({
|
||||
id,
|
||||
@@ -69,7 +77,7 @@ export class ProxyController {
|
||||
|
||||
@Post('chat/completions')
|
||||
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
||||
await this.sendOpenAIChatCompletionResponse(body, res);
|
||||
await this.respondOpenAIChatCompletions(body, res);
|
||||
}
|
||||
|
||||
@Post('completions')
|
||||
@@ -86,11 +94,11 @@ export class ProxyController {
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const request: OpenAIChatRequest = {
|
||||
model: body.model ?? 'gemini-2.5-flash',
|
||||
model: body.model ?? 'gemini-3-flash',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: this.normalizePrompt(body.prompt),
|
||||
content: this.normalizeCompletionPrompt(body.prompt),
|
||||
},
|
||||
],
|
||||
max_tokens: body.max_tokens,
|
||||
@@ -106,17 +114,9 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
const response = result as OpenAIChatResponse;
|
||||
res.status(HttpStatus.OK).send(this.toLegacyCompletionsResponse(response));
|
||||
res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||
const status = this.resolveUpstreamStatus(message);
|
||||
this.logEndpointError('/v1/completions', status, message, error);
|
||||
res.status(status).send({
|
||||
error: {
|
||||
message,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
this.sendOpenAIErrorResponse(res, '/v1/completions', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ export class ProxyController {
|
||||
model?: string;
|
||||
instructions?: string;
|
||||
input?: unknown;
|
||||
tools?: OpenAIChatRequest['tools'];
|
||||
max_output_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
@@ -144,17 +145,9 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
const response = result as OpenAIChatResponse;
|
||||
res.status(HttpStatus.OK).send(this.toLegacyCompletionsResponse(response));
|
||||
res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||
const status = this.resolveUpstreamStatus(message);
|
||||
this.logEndpointError('/v1/responses', status, message, error);
|
||||
res.status(status).send({
|
||||
error: {
|
||||
message,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
this.sendOpenAIErrorResponse(res, '/v1/responses', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +175,7 @@ export class ProxyController {
|
||||
quality: body.quality,
|
||||
};
|
||||
|
||||
await this.sendOpenAIImageResponse(request, body.prompt ?? '', res);
|
||||
await this.sendOpenAIImageGenerationResponse(request, body.prompt ?? '', res);
|
||||
}
|
||||
|
||||
@Post('images/edits')
|
||||
@@ -200,7 +193,7 @@ export class ProxyController {
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
if (!this.hasValidMultipartBoundary(req)) {
|
||||
if (!this.hasMultipartBoundary(req)) {
|
||||
res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.send('Invalid `boundary` for `multipart/form-data` request');
|
||||
@@ -236,7 +229,7 @@ export class ProxyController {
|
||||
quality: body.quality,
|
||||
};
|
||||
|
||||
await this.sendOpenAIImageResponse(request, body.prompt ?? '', res);
|
||||
await this.sendOpenAIImageGenerationResponse(request, body.prompt ?? '', res);
|
||||
}
|
||||
|
||||
@Post('audio/transcriptions')
|
||||
@@ -251,7 +244,7 @@ export class ProxyController {
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
if (!this.hasValidMultipartBoundary(req)) {
|
||||
if (!this.hasMultipartBoundary(req)) {
|
||||
res
|
||||
.status(HttpStatus.BAD_REQUEST)
|
||||
.send('Invalid `boundary` for `multipart/form-data` request');
|
||||
@@ -271,7 +264,7 @@ export class ProxyController {
|
||||
|
||||
try {
|
||||
const result = await this.proxyService.handleGeminiGenerateContent(
|
||||
body.model ?? 'gemini-2.5-flash',
|
||||
body.model ?? 'gemini-3-flash',
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
@@ -298,19 +291,11 @@ export class ProxyController {
|
||||
text: text ?? '',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||
const status = this.resolveUpstreamStatus(message);
|
||||
this.logEndpointError('/v1/audio/transcriptions', status, message, error);
|
||||
res.status(status).send({
|
||||
error: {
|
||||
message,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
this.sendOpenAIErrorResponse(res, '/v1/audio/transcriptions', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendOpenAIChatCompletionResponse(body: OpenAIChatRequest, res: FastifyReply) {
|
||||
private async respondOpenAIChatCompletions(body: OpenAIChatRequest, res: FastifyReply) {
|
||||
try {
|
||||
const result = await this.proxyService.handleChatCompletions(body);
|
||||
|
||||
@@ -321,15 +306,7 @@ export class ProxyController {
|
||||
res.status(HttpStatus.OK).send(result);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||
const status = this.resolveUpstreamStatus(message);
|
||||
this.logEndpointError('/v1/chat/completions', status, message, error);
|
||||
res.status(status).send({
|
||||
error: {
|
||||
message: message,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
this.sendOpenAIErrorResponse(res, '/v1/chat/completions', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,20 +322,11 @@ export class ProxyController {
|
||||
res.status(HttpStatus.OK).send(result);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
||||
const status = this.resolveUpstreamStatus(message);
|
||||
this.logEndpointError('/v1/messages', status, message, error);
|
||||
res.status(status).send({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: message,
|
||||
},
|
||||
});
|
||||
this.sendAnthropicErrorResponse(res, '/v1/messages', error);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePrompt(prompt: string | string[] | undefined): string {
|
||||
private normalizeCompletionPrompt(prompt: string | string[] | undefined): string {
|
||||
if (!prompt) {
|
||||
return '';
|
||||
}
|
||||
@@ -368,7 +336,7 @@ export class ProxyController {
|
||||
return prompt;
|
||||
}
|
||||
|
||||
private toLegacyCompletionsResponse(response: OpenAIChatResponse): Record<string, unknown> {
|
||||
private toLegacyTextCompletionsResponse(response: OpenAIChatResponse): Record<string, unknown> {
|
||||
const choice = response.choices?.[0];
|
||||
const content = choice?.message?.content;
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
@@ -391,21 +359,20 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
private normalizeResponsesInput(input: unknown): string {
|
||||
if (typeof input === 'string') {
|
||||
if (isString(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
if (isString(item)) {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === 'object' && 'content' in item) {
|
||||
const content = (item as { content?: unknown }).content;
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
const itemRecord = this.toRecord(item);
|
||||
const content = this.asString(itemRecord?.content);
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
@@ -423,6 +390,7 @@ export class ProxyController {
|
||||
model?: string;
|
||||
instructions?: string;
|
||||
input?: unknown;
|
||||
tools?: OpenAIChatRequest['tools'];
|
||||
max_output_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
@@ -458,7 +426,7 @@ export class ProxyController {
|
||||
type === 'local_shell_call'
|
||||
? 'shell'
|
||||
: type === 'web_search_call'
|
||||
? 'google_search'
|
||||
? 'builtin_web_search'
|
||||
: (this.asString(itemObj.name) ?? 'unknown');
|
||||
callIdToToolName.set(callId, toolName);
|
||||
}
|
||||
@@ -536,8 +504,9 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
return {
|
||||
model: body.model ?? 'gemini-2.5-flash',
|
||||
model: body.model ?? 'gemini-3-flash',
|
||||
messages,
|
||||
tools: body.tools,
|
||||
max_tokens: body.max_output_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
@@ -546,7 +515,7 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
private normalizeResponsesMessageContent(content: unknown): string | OpenAIContentPart[] {
|
||||
if (typeof content === 'string') {
|
||||
if (isString(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -621,11 +590,12 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
const raw = item.arguments;
|
||||
if (typeof raw === 'string') {
|
||||
if (isString(raw)) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
const parsedRecord = this.toRecord(parsed);
|
||||
if (parsedRecord) {
|
||||
return parsedRecord;
|
||||
}
|
||||
return {
|
||||
value: parsed,
|
||||
@@ -637,23 +607,22 @@ export class ProxyController {
|
||||
}
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
return raw as Record<string, unknown>;
|
||||
const rawRecord = this.toRecord(raw);
|
||||
if (rawRecord) {
|
||||
return rawRecord;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private normalizeResponsesOutput(output: unknown): string {
|
||||
if (typeof output === 'string') {
|
||||
if (isString(output)) {
|
||||
return output;
|
||||
}
|
||||
if (output && typeof output === 'object' && !Array.isArray(output)) {
|
||||
const obj = output as Record<string, unknown>;
|
||||
const content = this.asString(obj.content);
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
const outputRecord = this.toRecord(output);
|
||||
const content = this.asString(outputRecord?.content);
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
if (isNil(output)) {
|
||||
return '';
|
||||
@@ -663,15 +632,13 @@ export class ProxyController {
|
||||
|
||||
private resolveImageUrl(block: Record<string, unknown>): string | null {
|
||||
const raw = block.image_url;
|
||||
if (typeof raw === 'string') {
|
||||
if (isString(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
const record = raw as Record<string, unknown>;
|
||||
const url = this.asString(record.url);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
const rawRecord = this.toRecord(raw);
|
||||
const url = this.asString(rawRecord?.url);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -703,7 +670,7 @@ export class ProxyController {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
if (isString(input)) {
|
||||
const dataUri = input.match(/^data:(?<mime>[^;]+);base64,(?<data>[A-Za-z0-9+/=]+)$/);
|
||||
if (dataUri?.groups?.mime && dataUri.groups.data) {
|
||||
return {
|
||||
@@ -722,14 +689,14 @@ export class ProxyController {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof input === 'object' && !Array.isArray(input)) {
|
||||
const obj = input as Record<string, unknown>;
|
||||
const data = this.asString(obj.data);
|
||||
const inputRecord = this.toRecord(input);
|
||||
if (inputRecord) {
|
||||
const data = this.asString(inputRecord.data);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mimeType: this.asString(obj.mimeType) ?? 'audio/mpeg',
|
||||
mimeType: this.asString(inputRecord.mimeType) ?? 'audio/mpeg',
|
||||
data,
|
||||
};
|
||||
}
|
||||
@@ -738,14 +705,14 @@ export class ProxyController {
|
||||
}
|
||||
|
||||
private toRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
if (!isPlainObject(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private asString(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
return isString(value) ? value : null;
|
||||
}
|
||||
|
||||
private isObservableLike(value: unknown): value is Observable<unknown> {
|
||||
@@ -814,7 +781,7 @@ export class ProxyController {
|
||||
});
|
||||
}
|
||||
|
||||
private async sendOpenAIImageResponse(
|
||||
private async sendOpenAIImageGenerationResponse(
|
||||
request: OpenAIChatRequest,
|
||||
prompt: string,
|
||||
res: FastifyReply,
|
||||
@@ -822,7 +789,7 @@ export class ProxyController {
|
||||
try {
|
||||
const result = await this.proxyService.handleChatCompletions(request);
|
||||
if (result instanceof Observable) {
|
||||
this.logEndpointError(
|
||||
this.logProxyEndpointError(
|
||||
'/v1/images/generations',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
'Streaming image generation is not supported by this endpoint',
|
||||
@@ -839,7 +806,7 @@ export class ProxyController {
|
||||
const content = result.choices?.[0]?.message?.content;
|
||||
const image = this.extractInlineBase64Image(typeof content === 'string' ? content : '');
|
||||
if (!image) {
|
||||
this.logEndpointError(
|
||||
this.logProxyEndpointError(
|
||||
'/v1/images/generations',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
'Upstream did not return inline image data',
|
||||
@@ -889,14 +856,7 @@ export class ProxyController {
|
||||
}
|
||||
}
|
||||
|
||||
const status = this.resolveUpstreamStatus(message);
|
||||
this.logEndpointError('/v1/images/generations', status, message, error);
|
||||
res.status(status).send({
|
||||
error: {
|
||||
message,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
this.sendOpenAIErrorResponse(res, '/v1/images/generations', error, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,7 +962,7 @@ export class ProxyController {
|
||||
);
|
||||
}
|
||||
|
||||
private hasValidMultipartBoundary(req: FastifyRequest): boolean {
|
||||
private hasMultipartBoundary(req: FastifyRequest): boolean {
|
||||
const contentType = req.headers['content-type'];
|
||||
if (typeof contentType !== 'string') {
|
||||
return false;
|
||||
@@ -1012,7 +972,46 @@ export class ProxyController {
|
||||
return lowered.includes('multipart/form-data') && lowered.includes('boundary=');
|
||||
}
|
||||
|
||||
private resolveUpstreamStatus(message: string): HttpStatus {
|
||||
private resolveErrorMessageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Internal Server Error';
|
||||
}
|
||||
|
||||
private sendOpenAIErrorResponse(
|
||||
res: FastifyReply,
|
||||
endpoint: string,
|
||||
error: unknown,
|
||||
overrideMessage?: string,
|
||||
): void {
|
||||
const message = overrideMessage ?? this.resolveErrorMessageText(error);
|
||||
const status = this.resolveErrorHttpStatus(message);
|
||||
this.logProxyEndpointError(endpoint, status, message, error);
|
||||
res.status(status).send({
|
||||
error: {
|
||||
message,
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private sendAnthropicErrorResponse(
|
||||
res: FastifyReply,
|
||||
endpoint: string,
|
||||
error: unknown,
|
||||
overrideMessage?: string,
|
||||
): void {
|
||||
const message = overrideMessage ?? this.resolveErrorMessageText(error);
|
||||
const status = this.resolveErrorHttpStatus(message);
|
||||
this.logProxyEndpointError(endpoint, status, message, error);
|
||||
res.status(status).send({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private resolveErrorHttpStatus(message: string): HttpStatus {
|
||||
const lowered = message.toLowerCase();
|
||||
if (lowered.includes('all accounts failed or unhealthy')) {
|
||||
return HttpStatus.SERVICE_UNAVAILABLE;
|
||||
@@ -1050,7 +1049,7 @@ export class ProxyController {
|
||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
private logEndpointError(
|
||||
private logProxyEndpointError(
|
||||
endpoint: string,
|
||||
status: HttpStatus,
|
||||
message: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { isEmpty, isNil } from 'lodash-es';
|
||||
import { isEmpty, isNil, isPlainObject, isString } from 'lodash-es';
|
||||
import { TokenManagerService } from './token-manager.service';
|
||||
import { GeminiClient } from './clients/gemini.client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -14,10 +14,8 @@ import {
|
||||
GeminiPart as InternalGeminiPart,
|
||||
} from '../../../lib/antigravity/types';
|
||||
import { calculateRetryDelay, sleep } from '../../../lib/antigravity/retry-utils';
|
||||
import {
|
||||
classifyStreamError,
|
||||
formatErrorForSSE,
|
||||
} from '../../../lib/antigravity/stream-error-utils';
|
||||
import { normalizeObjectJsonSchema } from '../../../lib/antigravity/JsonSchemaUtils';
|
||||
import { classifyStreamError } from '../../../lib/antigravity/stream-error-utils';
|
||||
import {
|
||||
OpenAIChatRequest,
|
||||
AnthropicChatRequest,
|
||||
@@ -28,12 +26,17 @@ import {
|
||||
AnthropicContent,
|
||||
} from './interfaces/request-interfaces';
|
||||
import { getServerConfig } from '../../server-config';
|
||||
import { resolveModelRoute } from '../../../lib/antigravity/ModelMapping';
|
||||
import {
|
||||
normalizeGeminiModelAlias,
|
||||
resolveModelRoute,
|
||||
} from '../../../lib/antigravity/ModelMapping';
|
||||
import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/antigravity/ModelSpecs';
|
||||
import { resolveRequestUserAgent } from './request-user-agent';
|
||||
import { UpstreamRequestError } from './clients/upstream-error';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyService {
|
||||
private readonly logger = new Logger(ProxyService.name);
|
||||
private readonly defaultRequestUserAgent = 'antigravity/1.11.9 windows/amd64';
|
||||
|
||||
constructor(
|
||||
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
|
||||
@@ -45,12 +48,12 @@ export class ProxyService {
|
||||
async handleAnthropicMessages(
|
||||
request: AnthropicChatRequest,
|
||||
): Promise<AnthropicChatResponse | Observable<string>> {
|
||||
const sessionKey = this.getSessionKeyFromAnthropic(request);
|
||||
const sessionKey = this.extractAnthropicSessionKey(request);
|
||||
|
||||
const targetModel = this.mapModel(request.model);
|
||||
const extraHeaders = this.buildModelSpecificHeaders(request.model);
|
||||
const targetModel = this.resolveTargetModel(request.model);
|
||||
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||
this.logger.log(
|
||||
`Received Anthropic request for model: ${request.model} (Mapped: ${targetModel}, Stream: ${request.stream})`,
|
||||
`Anthropic request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`,
|
||||
);
|
||||
|
||||
// Retry loop
|
||||
@@ -61,22 +64,34 @@ export class ProxyService {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (i > 0) {
|
||||
const delay = calculateRetryDelay(i - 1);
|
||||
this.logger.log(`Retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms (jittered)`);
|
||||
this.logger.log(`Anthropic retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`);
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
const token = await this.tokenManager.getNextToken({
|
||||
sessionKey,
|
||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||
model: targetModel,
|
||||
});
|
||||
if (!token) {
|
||||
throw new Error('No available accounts');
|
||||
}
|
||||
attemptedAccountIds.add(token.id);
|
||||
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||
token.id,
|
||||
targetModel,
|
||||
);
|
||||
|
||||
try {
|
||||
const projectId = token.token.project_id ?? '';
|
||||
const geminiBody = transformClaudeRequestIn(this.toClaudeRequest(request), projectId);
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const geminiBody = transformClaudeRequestIn(
|
||||
this.toClaudeRequest(request),
|
||||
projectId,
|
||||
requestUserAgent,
|
||||
);
|
||||
geminiBody.model = effectiveTargetModel;
|
||||
this.applyInternalGenerationConstraints(geminiBody, effectiveTargetModel, token.id);
|
||||
|
||||
if (request.stream) {
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
@@ -101,7 +116,14 @@ export class ProxyService {
|
||||
`Anthropic request hit project context issue, retrying without project: ${error.message}`,
|
||||
);
|
||||
try {
|
||||
const fallbackBody = transformClaudeRequestIn(this.toClaudeRequest(request), '');
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const fallbackBody = transformClaudeRequestIn(
|
||||
this.toClaudeRequest(request),
|
||||
'',
|
||||
requestUserAgent,
|
||||
);
|
||||
fallbackBody.model = effectiveTargetModel;
|
||||
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||
if (request.stream) {
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
fallbackBody,
|
||||
@@ -126,17 +148,20 @@ export class ProxyService {
|
||||
|
||||
if (error instanceof Error && this.isQuotaExhaustedError(error.message)) {
|
||||
this.logger.warn(
|
||||
`Anthropic request hit quota exhaustion on mapped model, retrying with fallback model gemini-2.5-flash: ${error.message}`,
|
||||
`Anthropic request hit quota exhaustion on mapped model, retrying with fallback model gemini-3-flash: ${error.message}`,
|
||||
);
|
||||
try {
|
||||
const downgradedRequest: ClaudeRequest = {
|
||||
...this.toClaudeRequest(request),
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-3-flash',
|
||||
};
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const downgradedBody = transformClaudeRequestIn(
|
||||
downgradedRequest,
|
||||
token.token.project_id ?? '',
|
||||
requestUserAgent,
|
||||
);
|
||||
this.applyInternalGenerationConstraints(downgradedBody, 'gemini-3-flash', token.id);
|
||||
if (request.stream) {
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
downgradedBody,
|
||||
@@ -164,17 +189,7 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
if (error instanceof Error) {
|
||||
this.logger.warn(`Anthropic Request failed: ${error.message}`);
|
||||
const decision = this.classifyUpstreamError(error.message);
|
||||
if (decision.retry) {
|
||||
if (decision.markAsForbidden) {
|
||||
this.tokenManager.markAsForbidden(token.id);
|
||||
} else if (decision.markAsRateLimited) {
|
||||
this.tokenManager.markAsRateLimited(token.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, error);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('Request failed after retries');
|
||||
@@ -182,7 +197,7 @@ export class ProxyService {
|
||||
|
||||
private processAnthropicInternalStream(
|
||||
upstreamStream: NodeJS.ReadableStream,
|
||||
model: string,
|
||||
_model: string,
|
||||
): Observable<string> {
|
||||
return new Observable<string>((subscriber) => {
|
||||
const decoder = new TextDecoder();
|
||||
@@ -255,12 +270,9 @@ export class ProxyService {
|
||||
|
||||
upstreamStream.on('error', (err: unknown) => {
|
||||
const cleanError = err instanceof Error ? err : new Error(String(err));
|
||||
const { type, message } = classifyStreamError(cleanError);
|
||||
const { type } = classifyStreamError(cleanError);
|
||||
|
||||
this.logger.error(`Stream error: ${type} - ${cleanError.message}`);
|
||||
|
||||
// Send SSE error event before closing
|
||||
subscriber.next(formatErrorForSSE(type, message));
|
||||
subscriber.error(cleanError);
|
||||
});
|
||||
});
|
||||
@@ -272,10 +284,10 @@ export class ProxyService {
|
||||
request: GeminiRequest,
|
||||
): Promise<GeminiResponse> {
|
||||
const normalizedModel = this.normalizeGeminiModel(model);
|
||||
const targetModel = this.mapModel(normalizedModel);
|
||||
const extraHeaders = this.buildModelSpecificHeaders(normalizedModel);
|
||||
const targetModel = this.resolveTargetModel(normalizedModel);
|
||||
const extraHeaders = this.createModelSpecificHeaders(normalizedModel);
|
||||
this.logger.log(
|
||||
`Received Gemini request for model: ${normalizedModel} (Mapped: ${targetModel})`,
|
||||
`Gemini generate request received: model=${normalizedModel}, mappedModel=${targetModel}`,
|
||||
);
|
||||
|
||||
let lastError: unknown = null;
|
||||
@@ -291,19 +303,27 @@ export class ProxyService {
|
||||
|
||||
const token = await this.tokenManager.getNextToken({
|
||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||
model: targetModel,
|
||||
});
|
||||
if (!token) {
|
||||
throw new Error('No available accounts (all exhausted or rate limited)');
|
||||
}
|
||||
attemptedAccountIds.add(token.id);
|
||||
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||
token.id,
|
||||
targetModel,
|
||||
);
|
||||
|
||||
try {
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const internalBody = this.createGeminiInternalRequest(
|
||||
targetModel,
|
||||
effectiveTargetModel,
|
||||
request,
|
||||
token.token.project_id ?? '',
|
||||
'generate-content',
|
||||
requestUserAgent,
|
||||
);
|
||||
this.applyInternalGenerationConstraints(internalBody, effectiveTargetModel, token.id);
|
||||
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
internalBody,
|
||||
@@ -319,12 +339,15 @@ export class ProxyService {
|
||||
`Gemini request hit project context issue, retrying without project: ${err.message}`,
|
||||
);
|
||||
try {
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const fallbackBody = this.createGeminiInternalRequest(
|
||||
targetModel,
|
||||
effectiveTargetModel,
|
||||
request,
|
||||
'',
|
||||
'generate-content',
|
||||
requestUserAgent,
|
||||
);
|
||||
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||
const response = await this.generateInternalWithStreamFallback(
|
||||
fallbackBody,
|
||||
token.token.access_token,
|
||||
@@ -339,17 +362,7 @@ export class ProxyService {
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (lastError instanceof Error) {
|
||||
this.logger.warn(`Gemini request failed with account ${token.email}: ${lastError.message}`);
|
||||
const decision = this.classifyUpstreamError(lastError.message);
|
||||
if (decision.retry) {
|
||||
if (decision.markAsForbidden) {
|
||||
this.tokenManager.markAsForbidden(token.id);
|
||||
} else if (decision.markAsRateLimited) {
|
||||
this.tokenManager.markAsRateLimited(token.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,10 +374,10 @@ export class ProxyService {
|
||||
request: GeminiRequest,
|
||||
): Promise<Observable<string>> {
|
||||
const normalizedModel = this.normalizeGeminiModel(model);
|
||||
const targetModel = this.mapModel(normalizedModel);
|
||||
const extraHeaders = this.buildModelSpecificHeaders(normalizedModel);
|
||||
const targetModel = this.resolveTargetModel(normalizedModel);
|
||||
const extraHeaders = this.createModelSpecificHeaders(normalizedModel);
|
||||
this.logger.log(
|
||||
`Received Gemini stream request for model: ${normalizedModel} (Mapped: ${targetModel})`,
|
||||
`Gemini stream request received: model=${normalizedModel}, mappedModel=${targetModel}`,
|
||||
);
|
||||
|
||||
let lastError: unknown = null;
|
||||
@@ -380,19 +393,27 @@ export class ProxyService {
|
||||
|
||||
const token = await this.tokenManager.getNextToken({
|
||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||
model: targetModel,
|
||||
});
|
||||
if (!token) {
|
||||
throw new Error('No available accounts (all exhausted or rate limited)');
|
||||
}
|
||||
attemptedAccountIds.add(token.id);
|
||||
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||
token.id,
|
||||
targetModel,
|
||||
);
|
||||
|
||||
try {
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const internalBody = this.createGeminiInternalRequest(
|
||||
targetModel,
|
||||
effectiveTargetModel,
|
||||
request,
|
||||
token.token.project_id ?? '',
|
||||
'generate-content',
|
||||
requestUserAgent,
|
||||
);
|
||||
this.applyInternalGenerationConstraints(internalBody, effectiveTargetModel, token.id);
|
||||
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
internalBody,
|
||||
@@ -407,12 +428,15 @@ export class ProxyService {
|
||||
`Gemini stream request hit project context issue, retrying without project: ${err.message}`,
|
||||
);
|
||||
try {
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const fallbackBody = this.createGeminiInternalRequest(
|
||||
targetModel,
|
||||
effectiveTargetModel,
|
||||
request,
|
||||
'',
|
||||
'generate-content',
|
||||
requestUserAgent,
|
||||
);
|
||||
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
fallbackBody,
|
||||
token.token.access_token,
|
||||
@@ -427,19 +451,7 @@ export class ProxyService {
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (lastError instanceof Error) {
|
||||
this.logger.warn(
|
||||
`Gemini stream request failed with account ${token.email}: ${lastError.message}`,
|
||||
);
|
||||
const decision = this.classifyUpstreamError(lastError.message);
|
||||
if (decision.retry) {
|
||||
if (decision.markAsForbidden) {
|
||||
this.tokenManager.markAsForbidden(token.id);
|
||||
} else if (decision.markAsRateLimited) {
|
||||
this.tokenManager.markAsRateLimited(token.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,11 +487,130 @@ export class ProxyService {
|
||||
return model.replace(/^models\//i, '');
|
||||
}
|
||||
|
||||
private normalizeModelIdentifier(model: string): string {
|
||||
return model.replace(/^models\//i, '').trim();
|
||||
}
|
||||
|
||||
private resolveThinkingLevelBudget(level: string): number | undefined {
|
||||
const normalized = level.trim().toUpperCase();
|
||||
if (normalized === 'NONE') {
|
||||
return 0;
|
||||
}
|
||||
if (normalized === 'LOW') {
|
||||
return 4096;
|
||||
}
|
||||
if (normalized === 'MEDIUM') {
|
||||
return 8192;
|
||||
}
|
||||
if (normalized === 'HIGH') {
|
||||
return 24576;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getModelOutputCap(accountId: string, model: string): number {
|
||||
const normalizedModel = this.normalizeModelIdentifier(model);
|
||||
const dynamicCap = this.tokenManager.getModelOutputLimitForAccount(accountId, normalizedModel);
|
||||
if (typeof dynamicCap === 'number' && Number.isFinite(dynamicCap) && dynamicCap > 0) {
|
||||
return Math.floor(dynamicCap);
|
||||
}
|
||||
return getMaxOutputTokens(normalizedModel);
|
||||
}
|
||||
|
||||
private getModelThinkingBudget(accountId: string, model: string): number {
|
||||
const normalizedModel = this.normalizeModelIdentifier(model);
|
||||
const dynamicBudget = this.tokenManager.getModelThinkingBudgetForAccount(
|
||||
accountId,
|
||||
normalizedModel,
|
||||
);
|
||||
if (typeof dynamicBudget === 'number' && Number.isFinite(dynamicBudget) && dynamicBudget >= 0) {
|
||||
return Math.floor(dynamicBudget);
|
||||
}
|
||||
return getThinkingBudget(normalizedModel);
|
||||
}
|
||||
|
||||
private applyInternalGenerationConstraints(
|
||||
body: GeminiInternalRequest,
|
||||
model: string,
|
||||
accountId: string,
|
||||
): void {
|
||||
const generationConfig = body.request.generationConfig;
|
||||
if (!generationConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputCap = this.getModelOutputCap(accountId, model);
|
||||
const thinkingBudgetCap = this.getModelThinkingBudget(accountId, model);
|
||||
const normalizedModel = this.normalizeModelIdentifier(model).toLowerCase();
|
||||
const isClaudeModel = normalizedModel.includes('claude');
|
||||
const thinkingConfig = generationConfig.thinkingConfig as
|
||||
| ({ thinkingLevel?: string; thinkingBudget?: number } & Record<string, unknown>)
|
||||
| undefined;
|
||||
const adaptiveSentinel =
|
||||
thinkingConfig &&
|
||||
(typeof thinkingConfig.thinkingLevel === 'string' ||
|
||||
thinkingConfig.thinkingBudget === -1 ||
|
||||
thinkingConfig.thinkingBudget === 32768);
|
||||
|
||||
if (thinkingConfig) {
|
||||
if (!isClaudeModel && typeof thinkingConfig.thinkingLevel === 'string') {
|
||||
const converted = this.resolveThinkingLevelBudget(thinkingConfig.thinkingLevel);
|
||||
if (converted !== undefined) {
|
||||
thinkingConfig.thinkingBudget = converted;
|
||||
}
|
||||
delete thinkingConfig.thinkingLevel;
|
||||
}
|
||||
|
||||
if (typeof thinkingConfig.thinkingBudget === 'number' && thinkingConfig.thinkingBudget < 0) {
|
||||
thinkingConfig.thinkingBudget = Math.min(thinkingBudgetCap, 24576);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof thinkingConfig.thinkingBudget === 'number' &&
|
||||
Number.isFinite(thinkingConfig.thinkingBudget)
|
||||
) {
|
||||
thinkingConfig.thinkingBudget = Math.min(
|
||||
Math.floor(thinkingConfig.thinkingBudget),
|
||||
Math.max(0, outputCap - 1),
|
||||
thinkingBudgetCap,
|
||||
);
|
||||
|
||||
if (adaptiveSentinel) {
|
||||
if (
|
||||
generationConfig.maxOutputTokens === undefined ||
|
||||
generationConfig.maxOutputTokens < 131072
|
||||
) {
|
||||
generationConfig.maxOutputTokens = 131072;
|
||||
}
|
||||
} else if (
|
||||
generationConfig.maxOutputTokens === undefined ||
|
||||
generationConfig.maxOutputTokens <= thinkingConfig.thinkingBudget
|
||||
) {
|
||||
const hasExplicitMax = generationConfig.maxOutputTokens !== undefined;
|
||||
const overhead = hasExplicitMax ? 8192 : 32768;
|
||||
const minRequired = Math.min(outputCap, thinkingConfig.thinkingBudget + overhead);
|
||||
generationConfig.maxOutputTokens = minRequired;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof generationConfig.maxOutputTokens === 'number' &&
|
||||
Number.isFinite(generationConfig.maxOutputTokens)
|
||||
) {
|
||||
generationConfig.maxOutputTokens = Math.min(
|
||||
Math.floor(generationConfig.maxOutputTokens),
|
||||
outputCap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createGeminiInternalRequest(
|
||||
model: string,
|
||||
request: GeminiRequest,
|
||||
projectId: string | undefined,
|
||||
requestType: string,
|
||||
requestUserAgent: string,
|
||||
): GeminiInternalRequest {
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
|
||||
@@ -487,7 +618,7 @@ export class ProxyService {
|
||||
requestId: uuidv4(),
|
||||
request: this.toInternalGeminiRequest(request),
|
||||
model,
|
||||
userAgent: process.env.PROXY_REQUEST_USER_AGENT ?? this.defaultRequestUserAgent,
|
||||
userAgent: requestUserAgent,
|
||||
requestType,
|
||||
};
|
||||
|
||||
@@ -523,9 +654,6 @@ export class ProxyService {
|
||||
if (usage.totalTokenCount !== undefined) {
|
||||
usageMetadata.totalTokenCount = usage.totalTokenCount;
|
||||
}
|
||||
if (usage.thoughtsTokenCount !== undefined) {
|
||||
usageMetadata.thoughtsTokenCount = usage.thoughtsTokenCount;
|
||||
}
|
||||
if (usage.promptTokensDetails !== undefined) {
|
||||
usageMetadata.promptTokensDetails = usage.promptTokensDetails;
|
||||
}
|
||||
@@ -546,12 +674,12 @@ export class ProxyService {
|
||||
async handleChatCompletions(
|
||||
request: OpenAIChatRequest,
|
||||
): Promise<OpenAIChatResponse | Observable<string>> {
|
||||
const sessionKey = this.getSessionKeyFromOpenAI(request);
|
||||
const sessionKey = this.extractOpenAISessionKey(request);
|
||||
|
||||
const targetModel = this.mapModel(request.model);
|
||||
const extraHeaders = this.buildModelSpecificHeaders(request.model);
|
||||
const targetModel = this.resolveTargetModel(request.model);
|
||||
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||
this.logger.log(
|
||||
`Received request for model: ${request.model} (Mapped: ${targetModel}, Stream: ${request.stream})`,
|
||||
`OpenAI-compatible request received: model=${request.model}, mappedModel=${targetModel}, stream=${request.stream}`,
|
||||
);
|
||||
|
||||
// Retry loop for account selection
|
||||
@@ -562,7 +690,9 @@ export class ProxyService {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (i > 0) {
|
||||
const delay = calculateRetryDelay(i - 1);
|
||||
this.logger.log(`Retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms (jittered)`);
|
||||
this.logger.log(
|
||||
`OpenAI-compatible retry ${i + 1}/${maxRetries}, backoff=${delay}ms (jittered)`,
|
||||
);
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
@@ -570,16 +700,24 @@ export class ProxyService {
|
||||
const token = await this.tokenManager.getNextToken({
|
||||
sessionKey,
|
||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||
model: targetModel,
|
||||
});
|
||||
if (!token) {
|
||||
throw new Error('No available accounts (all exhausted or rate limited)');
|
||||
}
|
||||
attemptedAccountIds.add(token.id);
|
||||
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||
token.id,
|
||||
targetModel,
|
||||
);
|
||||
|
||||
try {
|
||||
const claudeRequest = this.convertOpenAIToClaude(request);
|
||||
const projectId = token.token.project_id ?? '';
|
||||
const geminiBody = transformClaudeRequestIn(claudeRequest, projectId);
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const geminiBody = transformClaudeRequestIn(claudeRequest, projectId, requestUserAgent);
|
||||
geminiBody.model = effectiveTargetModel;
|
||||
this.applyInternalGenerationConstraints(geminiBody, effectiveTargetModel, token.id);
|
||||
|
||||
// Use v1internal API (same as Anthropic handler)
|
||||
if (request.stream) {
|
||||
@@ -593,7 +731,7 @@ export class ProxyService {
|
||||
return this.processStreamResponse(stream, request.model);
|
||||
} catch (streamError) {
|
||||
this.logger.warn(
|
||||
`Stream request failed for ${request.model}, falling back to non-stream generation: ${
|
||||
`Stream path failed for model=${request.model}; falling back to non-stream generation: ${
|
||||
streamError instanceof Error ? streamError.message : String(streamError)
|
||||
}`,
|
||||
);
|
||||
@@ -605,7 +743,7 @@ export class ProxyService {
|
||||
extraHeaders,
|
||||
);
|
||||
this.logger.log(
|
||||
`Upstream response snippet (stream fallback): ${JSON.stringify(response).substring(0, 500)}`,
|
||||
`Upstream response snippet after stream fallback: ${JSON.stringify(response).substring(0, 500)}`,
|
||||
);
|
||||
const claudeResponse = transformResponse(response);
|
||||
const openaiResponse = this.convertClaudeToOpenAIResponse(
|
||||
@@ -622,11 +760,13 @@ export class ProxyService {
|
||||
extraHeaders,
|
||||
);
|
||||
this.logger.log(
|
||||
`Upstream response snippet: ${JSON.stringify(response).substring(0, 500)}`,
|
||||
`Upstream response snippet (non-stream): ${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)}`);
|
||||
this.logger.log(
|
||||
`Transformed Claude response snippet: ${JSON.stringify(claudeResponse).substring(0, 500)}`,
|
||||
);
|
||||
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -636,7 +776,10 @@ export class ProxyService {
|
||||
);
|
||||
try {
|
||||
const claudeRequest = this.convertOpenAIToClaude(request);
|
||||
const fallbackBody = transformClaudeRequestIn(claudeRequest, '');
|
||||
const requestUserAgent = await resolveRequestUserAgent();
|
||||
const fallbackBody = transformClaudeRequestIn(claudeRequest, '', requestUserAgent);
|
||||
fallbackBody.model = effectiveTargetModel;
|
||||
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||
if (request.stream) {
|
||||
const stream = await this.geminiClient.streamGenerateInternal(
|
||||
fallbackBody,
|
||||
@@ -662,18 +805,7 @@ export class ProxyService {
|
||||
lastError = err;
|
||||
}
|
||||
|
||||
if (lastError instanceof Error) {
|
||||
this.logger.warn(`Request failed with account ${token.email}: ${lastError.message}`);
|
||||
|
||||
const decision = this.classifyUpstreamError(lastError.message);
|
||||
if (decision.retry) {
|
||||
if (decision.markAsForbidden) {
|
||||
this.tokenManager.markAsForbidden(token.id);
|
||||
} else if (decision.markAsRateLimited) {
|
||||
this.tokenManager.markAsRateLimited(token.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('Request failed after retries');
|
||||
@@ -937,7 +1069,7 @@ export class ProxyService {
|
||||
hasSentDone = true;
|
||||
subscriber.complete();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
@@ -969,6 +1101,7 @@ export class ProxyService {
|
||||
upstreamStream.on('error', (err: unknown) => {
|
||||
// Convert to clean Error to avoid circular reference issues (socket objects)
|
||||
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
|
||||
this.logger.error(`OpenAI-compatible stream error: ${cleanError.message}`);
|
||||
subscriber.error(cleanError);
|
||||
});
|
||||
});
|
||||
@@ -1228,11 +1361,51 @@ export class ProxyService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return tools.map((tool) => ({
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters || { type: 'object', properties: {} },
|
||||
}));
|
||||
const result: NonNullable<AnthropicChatRequest['tools']> = [];
|
||||
const searchToolTypes = new Set([
|
||||
'web_search_20250305',
|
||||
'google_search',
|
||||
'google_search_retrieval',
|
||||
'builtin_web_search',
|
||||
]);
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolType = isString(tool.type) ? tool.type.toLowerCase() : '';
|
||||
const functionName = isString(tool.function?.name) ? tool.function.name : '';
|
||||
const normalizedFunctionName = functionName.toLowerCase();
|
||||
const isSearchTool =
|
||||
searchToolTypes.has(toolType) || searchToolTypes.has(normalizedFunctionName);
|
||||
|
||||
if (isSearchTool) {
|
||||
result.push({
|
||||
name: functionName || 'builtin_web_search',
|
||||
type: 'web_search_20250305',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!tool.function || !functionName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputSchema = normalizeObjectJsonSchema(tool.function.parameters);
|
||||
|
||||
result.push({
|
||||
name: functionName,
|
||||
description: tool.function.description,
|
||||
input_schema: inputSchema,
|
||||
});
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
private mapGeminiFinishReasonToOpenAIFinishReason(finishReason?: string): string | null {
|
||||
@@ -1362,7 +1535,7 @@ export class ProxyService {
|
||||
};
|
||||
}
|
||||
|
||||
private mapModel(model: string): string {
|
||||
private resolveTargetModel(model: string): string {
|
||||
const normalizedModel = model.replace(/^models\//i, '').trim();
|
||||
const config = getServerConfig();
|
||||
const configuredMapping = {
|
||||
@@ -1400,23 +1573,66 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
const routedModel = resolveModelRoute(normalizedModel, customExactMapping, {}, {});
|
||||
|
||||
return routedModel;
|
||||
return normalizeGeminiModelAlias(routedModel);
|
||||
}
|
||||
|
||||
private classifyUpstreamError(errorMessage: string): {
|
||||
private async applyUpstreamPenalty(
|
||||
accountId: string,
|
||||
model: string,
|
||||
error: unknown,
|
||||
): Promise<void> {
|
||||
this.tokenManager.recordParityError();
|
||||
|
||||
if (error instanceof UpstreamRequestError) {
|
||||
const status = error.status;
|
||||
if (status === 401 || status === 403) {
|
||||
this.tokenManager.markAsForbidden(accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.tokenManager.markFromUpstreamError({
|
||||
accountIdOrEmail: accountId,
|
||||
status,
|
||||
retryAfter: error.headers?.retryAfter,
|
||||
body: error.body,
|
||||
model,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn(`Upstream request failed for account ${accountId}: ${error.message}`);
|
||||
const penaltyDecision = this.classifyUpstreamFailure(error.message);
|
||||
if (!penaltyDecision.retry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (penaltyDecision.markAsForbidden) {
|
||||
this.tokenManager.markAsForbidden(accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (penaltyDecision.markAsRateLimited) {
|
||||
this.tokenManager.markAsRateLimited(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
private classifyUpstreamFailure(errorMessage: string): {
|
||||
retry: boolean;
|
||||
markAsForbidden: boolean;
|
||||
markAsRateLimited: boolean;
|
||||
} {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
const normalizedErrorMessage = errorMessage.toLowerCase();
|
||||
const isForbidden =
|
||||
msg.includes('401') ||
|
||||
msg.includes('unauthorized') ||
|
||||
msg.includes('invalid_grant') ||
|
||||
msg.includes('403') ||
|
||||
msg.includes('permission_denied') ||
|
||||
msg.includes('forbidden');
|
||||
normalizedErrorMessage.includes('401') ||
|
||||
normalizedErrorMessage.includes('unauthorized') ||
|
||||
normalizedErrorMessage.includes('invalid_grant') ||
|
||||
normalizedErrorMessage.includes('403') ||
|
||||
normalizedErrorMessage.includes('permission_denied') ||
|
||||
normalizedErrorMessage.includes('forbidden');
|
||||
|
||||
if (isForbidden) {
|
||||
return {
|
||||
@@ -1427,28 +1643,28 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
const isRateLimitedSignal =
|
||||
msg.includes('429') ||
|
||||
msg.includes('resource_exhausted') ||
|
||||
msg.includes('quota') ||
|
||||
msg.includes('rate_limit') ||
|
||||
msg.includes('rate limit');
|
||||
normalizedErrorMessage.includes('429') ||
|
||||
normalizedErrorMessage.includes('resource_exhausted') ||
|
||||
normalizedErrorMessage.includes('quota') ||
|
||||
normalizedErrorMessage.includes('rate_limit') ||
|
||||
normalizedErrorMessage.includes('rate limit');
|
||||
|
||||
const shouldRetryByStatus =
|
||||
msg.includes('408') ||
|
||||
msg.includes('429') ||
|
||||
msg.includes('500') ||
|
||||
msg.includes('502') ||
|
||||
msg.includes('503') ||
|
||||
msg.includes('504');
|
||||
normalizedErrorMessage.includes('408') ||
|
||||
normalizedErrorMessage.includes('429') ||
|
||||
normalizedErrorMessage.includes('500') ||
|
||||
normalizedErrorMessage.includes('502') ||
|
||||
normalizedErrorMessage.includes('503') ||
|
||||
normalizedErrorMessage.includes('504');
|
||||
|
||||
const shouldRetryByKeyword =
|
||||
msg.includes('resource_exhausted') ||
|
||||
msg.includes('quota') ||
|
||||
msg.includes('rate_limit') ||
|
||||
msg.includes('timeout') ||
|
||||
msg.includes('socket hang up') ||
|
||||
msg.includes('empty response stream') ||
|
||||
msg.includes('connection reset');
|
||||
normalizedErrorMessage.includes('resource_exhausted') ||
|
||||
normalizedErrorMessage.includes('quota') ||
|
||||
normalizedErrorMessage.includes('rate_limit') ||
|
||||
normalizedErrorMessage.includes('timeout') ||
|
||||
normalizedErrorMessage.includes('socket hang up') ||
|
||||
normalizedErrorMessage.includes('empty response stream') ||
|
||||
normalizedErrorMessage.includes('connection reset');
|
||||
|
||||
if (shouldRetryByStatus || shouldRetryByKeyword) {
|
||||
return {
|
||||
@@ -1465,7 +1681,7 @@ export class ProxyService {
|
||||
};
|
||||
}
|
||||
|
||||
private buildModelSpecificHeaders(model: string | undefined): Record<string, string> {
|
||||
private createModelSpecificHeaders(model: string | undefined): Record<string, string> {
|
||||
if (!model) {
|
||||
return {};
|
||||
}
|
||||
@@ -1510,28 +1726,28 @@ export class ProxyService {
|
||||
);
|
||||
}
|
||||
|
||||
private getSessionKeyFromAnthropic(request: AnthropicChatRequest): string | undefined {
|
||||
private extractAnthropicSessionKey(request: AnthropicChatRequest): string | undefined {
|
||||
const metadata = request.metadata;
|
||||
const sessionCandidate =
|
||||
metadata?.session_id ?? metadata?.sessionId ?? metadata?.user_id ?? metadata?.userId;
|
||||
if (typeof sessionCandidate !== 'string' || sessionCandidate.trim() === '') {
|
||||
if (!isString(sessionCandidate) || sessionCandidate.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
return `anthropic:${sessionCandidate.trim()}`;
|
||||
}
|
||||
|
||||
private getSessionKeyFromOpenAI(request: OpenAIChatRequest): string | undefined {
|
||||
private extractOpenAISessionKey(request: OpenAIChatRequest): string | undefined {
|
||||
const extra = request.extra;
|
||||
const sessionCandidate =
|
||||
extra?.session_id ?? extra?.sessionId ?? extra?.user_id ?? extra?.userId;
|
||||
if (typeof sessionCandidate !== 'string' || sessionCandidate.trim() === '') {
|
||||
if (!isString(sessionCandidate) || sessionCandidate.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
return `openai:${sessionCandidate.trim()}`;
|
||||
}
|
||||
|
||||
private isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
return isPlainObject(value);
|
||||
}
|
||||
|
||||
private isGeminiPart(value: unknown): value is InternalGeminiPart {
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
export enum RateLimitReason {
|
||||
QuotaExhausted = 'quota_exhausted',
|
||||
RateLimitExceeded = 'rate_limit_exceeded',
|
||||
ModelCapacityExhausted = 'model_capacity_exhausted',
|
||||
ServerError = 'server_error',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
interface RateLimitInfo {
|
||||
resetTimeMs: number;
|
||||
retryAfterSec: number;
|
||||
reason: RateLimitReason;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
type FailureCountEntry = {
|
||||
count: number;
|
||||
lastFailureMs: number;
|
||||
};
|
||||
|
||||
interface GoogleErrorDetail {
|
||||
reason?: string;
|
||||
retryDelay?: string;
|
||||
metadata?: {
|
||||
quotaResetDelay?: string;
|
||||
retryDelay?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ParsedGoogleErrorBody {
|
||||
error?: {
|
||||
status?: string;
|
||||
details?: GoogleErrorDetail[];
|
||||
retry_after?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const FAILURE_COUNT_EXPIRY_MS = 60 * 60 * 1000;
|
||||
|
||||
function toLowerText(value: string | undefined): string {
|
||||
return (value ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(text: string): number | null {
|
||||
const match = text.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hours = Number(match[1] ?? 0);
|
||||
const minutes = Number(match[2] ?? 0);
|
||||
const seconds = Number(match[3] ?? 0);
|
||||
const milliseconds = Number(match[4] ?? 0);
|
||||
|
||||
const totalSeconds =
|
||||
hours * 3600 + minutes * 60 + Math.ceil(seconds) + Math.ceil(milliseconds / 1000);
|
||||
if (totalSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
return totalSeconds;
|
||||
}
|
||||
|
||||
function tryParseGoogleErrorBody(body: string | undefined): ParsedGoogleErrorBody | null {
|
||||
const trimmed = (body ?? '').trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as ParsedGoogleErrorBody;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapGoogleReasonToTrackerReason(reason: string): RateLimitReason | null {
|
||||
const normalizedReason = reason.trim().toUpperCase();
|
||||
if (normalizedReason === 'QUOTA_EXHAUSTED') {
|
||||
return RateLimitReason.QuotaExhausted;
|
||||
}
|
||||
if (normalizedReason === 'RATE_LIMIT_EXCEEDED') {
|
||||
return RateLimitReason.RateLimitExceeded;
|
||||
}
|
||||
if (normalizedReason === 'MODEL_CAPACITY_EXHAUSTED') {
|
||||
return RateLimitReason.ModelCapacityExhausted;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class RateLimitTracker {
|
||||
private readonly lockoutByKey = new Map<string, RateLimitInfo>();
|
||||
private readonly failureCounts = new Map<string, FailureCountEntry>();
|
||||
|
||||
private buildLockoutKey(accountId: string, model?: string): string {
|
||||
if (model && model.trim() !== '') {
|
||||
return `${accountId}:${model}`;
|
||||
}
|
||||
return accountId;
|
||||
}
|
||||
|
||||
getRemainingWaitSeconds(accountId: string, model?: string): number {
|
||||
const now = Date.now();
|
||||
|
||||
const globalLock = this.lockoutByKey.get(accountId);
|
||||
if (globalLock && globalLock.resetTimeMs > now) {
|
||||
return Math.max(0, Math.ceil((globalLock.resetTimeMs - now) / 1000));
|
||||
}
|
||||
|
||||
if (model) {
|
||||
const modelKey = this.buildLockoutKey(accountId, model);
|
||||
const modelLock = this.lockoutByKey.get(modelKey);
|
||||
if (modelLock && modelLock.resetTimeMs > now) {
|
||||
return Math.max(0, Math.ceil((modelLock.resetTimeMs - now) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
getRemainingWaitSec(accountId: string, model?: string): number {
|
||||
return this.getRemainingWaitSeconds(accountId, model);
|
||||
}
|
||||
|
||||
isRateLimited(accountId: string, model?: string): boolean {
|
||||
return this.getRemainingWaitSeconds(accountId, model) > 0;
|
||||
}
|
||||
|
||||
setLockoutUntilIso(
|
||||
accountId: string,
|
||||
resetTimeIso: string,
|
||||
reason: RateLimitReason,
|
||||
model?: string,
|
||||
): boolean {
|
||||
const timestamp = Date.parse(resetTimeIso);
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return false;
|
||||
}
|
||||
this.setLockoutUntil(accountId, timestamp, reason, model);
|
||||
return true;
|
||||
}
|
||||
|
||||
private setLockoutUntil(
|
||||
accountId: string,
|
||||
resetTimeMs: number,
|
||||
reason: RateLimitReason,
|
||||
model?: string,
|
||||
): void {
|
||||
const retryAfterSec = Math.max(2, Math.ceil((resetTimeMs - Date.now()) / 1000));
|
||||
const key = model && model.trim() !== '' ? this.buildLockoutKey(accountId, model) : accountId;
|
||||
this.lockoutByKey.set(key, {
|
||||
resetTimeMs,
|
||||
retryAfterSec,
|
||||
reason,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
clear(accountId: string): boolean {
|
||||
return this.lockoutByKey.delete(accountId);
|
||||
}
|
||||
|
||||
clearAll(): number {
|
||||
const size = this.lockoutByKey.size;
|
||||
this.lockoutByKey.clear();
|
||||
return size;
|
||||
}
|
||||
|
||||
cleanupExpired(): number {
|
||||
const now = Date.now();
|
||||
let deleted = 0;
|
||||
for (const [key, info] of this.lockoutByKey.entries()) {
|
||||
if (info.resetTimeMs <= now) {
|
||||
this.lockoutByKey.delete(key);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
markSuccess(accountId: string): void {
|
||||
this.failureCounts.delete(accountId);
|
||||
this.lockoutByKey.delete(accountId);
|
||||
}
|
||||
|
||||
trackFromUpstreamError(params: {
|
||||
accountId: string;
|
||||
status?: number;
|
||||
retryAfter?: string;
|
||||
body?: string;
|
||||
model?: string;
|
||||
backoffSteps: number[];
|
||||
}): RateLimitInfo | null {
|
||||
const status = params.status ?? 0;
|
||||
if (![404, 429, 500, 503, 529].includes(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reason = this.detectRateLimitReason(status, params.body);
|
||||
const retryAfterSec = this.computeRetryAfterSeconds({
|
||||
reason,
|
||||
status,
|
||||
retryAfter: params.retryAfter,
|
||||
body: params.body,
|
||||
accountId: params.accountId,
|
||||
backoffSteps: params.backoffSteps,
|
||||
});
|
||||
|
||||
const info: RateLimitInfo = {
|
||||
reason,
|
||||
retryAfterSec,
|
||||
resetTimeMs: Date.now() + retryAfterSec * 1000,
|
||||
model: params.model,
|
||||
};
|
||||
|
||||
const useModelKey = reason === RateLimitReason.QuotaExhausted && Boolean(params.model);
|
||||
const key = useModelKey
|
||||
? this.buildLockoutKey(params.accountId, params.model)
|
||||
: params.accountId;
|
||||
this.lockoutByKey.set(key, info);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
parseAndMarkFromError(params: {
|
||||
accountId: string;
|
||||
status?: number;
|
||||
retryAfter?: string;
|
||||
body?: string;
|
||||
model?: string;
|
||||
backoffSteps: number[];
|
||||
}): RateLimitInfo | null {
|
||||
return this.trackFromUpstreamError(params);
|
||||
}
|
||||
|
||||
private detectRateLimitReason(status: number, body: string | undefined): RateLimitReason {
|
||||
const parsedBody = tryParseGoogleErrorBody(body);
|
||||
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody?.error?.details : [];
|
||||
for (const detail of details) {
|
||||
if (typeof detail.reason !== 'string' || detail.reason.trim() === '') {
|
||||
continue;
|
||||
}
|
||||
const mappedReason = mapGoogleReasonToTrackerReason(detail.reason);
|
||||
if (mappedReason) {
|
||||
return mappedReason;
|
||||
}
|
||||
}
|
||||
|
||||
const statusFromBody = parsedBody?.error?.status?.trim().toUpperCase();
|
||||
if (statusFromBody === 'RESOURCE_EXHAUSTED') {
|
||||
return RateLimitReason.QuotaExhausted;
|
||||
}
|
||||
if (statusFromBody === 'UNAVAILABLE') {
|
||||
const loweredBody = toLowerText(body);
|
||||
if (loweredBody.includes('no capacity available') || loweredBody.includes('model capacity')) {
|
||||
return RateLimitReason.ModelCapacityExhausted;
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 429) {
|
||||
return RateLimitReason.ServerError;
|
||||
}
|
||||
|
||||
const lowerBody = toLowerText(body);
|
||||
if (
|
||||
lowerBody.includes('per minute') ||
|
||||
lowerBody.includes('rate limit') ||
|
||||
lowerBody.includes('rate_limit') ||
|
||||
lowerBody.includes('too many requests')
|
||||
) {
|
||||
return RateLimitReason.RateLimitExceeded;
|
||||
}
|
||||
if (
|
||||
lowerBody.includes('model capacity exhausted') ||
|
||||
lowerBody.includes('no capacity available')
|
||||
) {
|
||||
return RateLimitReason.ModelCapacityExhausted;
|
||||
}
|
||||
if (lowerBody.includes('quota') || lowerBody.includes('exhausted')) {
|
||||
return RateLimitReason.QuotaExhausted;
|
||||
}
|
||||
|
||||
return RateLimitReason.Unknown;
|
||||
}
|
||||
|
||||
private computeRetryAfterSeconds(params: {
|
||||
reason: RateLimitReason;
|
||||
status: number;
|
||||
retryAfter?: string;
|
||||
body?: string;
|
||||
accountId: string;
|
||||
backoffSteps: number[];
|
||||
}): number {
|
||||
const headerRetryRaw = params.retryAfter?.trim() ?? '';
|
||||
if (headerRetryRaw !== '') {
|
||||
const headerRetry = Number(headerRetryRaw);
|
||||
if (!Number.isNaN(headerRetry) && Number.isFinite(headerRetry) && headerRetry > 0) {
|
||||
return Math.max(2, Math.ceil(headerRetry));
|
||||
}
|
||||
|
||||
const headerRetryAt = Date.parse(headerRetryRaw);
|
||||
if (Number.isFinite(headerRetryAt)) {
|
||||
const retryAfterSec = Math.ceil((headerRetryAt - Date.now()) / 1000);
|
||||
if (retryAfterSec > 0) {
|
||||
return Math.max(2, retryAfterSec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyRetry = this.parseRetryAfterSecondsFromBody(params.body);
|
||||
if (bodyRetry !== null) {
|
||||
return Math.max(2, Math.ceil(bodyRetry));
|
||||
}
|
||||
|
||||
const failureCount =
|
||||
params.reason !== RateLimitReason.ServerError
|
||||
? this.incrementFailureCount(params.accountId)
|
||||
: 1;
|
||||
|
||||
if (params.reason === RateLimitReason.QuotaExhausted) {
|
||||
const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200];
|
||||
const index = Math.max(0, failureCount - 1);
|
||||
return steps[Math.min(index, steps.length - 1)];
|
||||
}
|
||||
|
||||
if (params.reason === RateLimitReason.RateLimitExceeded) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
if (params.reason === RateLimitReason.ModelCapacityExhausted) {
|
||||
if (failureCount <= 1) {
|
||||
return 5;
|
||||
}
|
||||
if (failureCount === 2) {
|
||||
return 10;
|
||||
}
|
||||
return 15;
|
||||
}
|
||||
|
||||
if (params.reason === RateLimitReason.ServerError) {
|
||||
return params.status === 404 ? 5 : 8;
|
||||
}
|
||||
|
||||
return 60;
|
||||
}
|
||||
|
||||
private parseRetryAfterSecondsFromBody(body: string | undefined): number | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
const parsedBody = tryParseGoogleErrorBody(body);
|
||||
const details = Array.isArray(parsedBody?.error?.details) ? parsedBody.error.details : [];
|
||||
for (const detail of details) {
|
||||
if (typeof detail.retryDelay === 'string' && detail.retryDelay.trim() !== '') {
|
||||
const parsedDelay = parseDurationToSeconds(detail.retryDelay);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof detail.metadata?.retryDelay === 'string' &&
|
||||
detail.metadata.retryDelay.trim() !== ''
|
||||
) {
|
||||
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof detail.metadata?.quotaResetDelay === 'string' &&
|
||||
detail.metadata.quotaResetDelay.trim() !== ''
|
||||
) {
|
||||
const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const retryAfter = parsedBody?.error?.retry_after;
|
||||
if (typeof retryAfter === 'number' && retryAfter > 0) {
|
||||
return Math.ceil(retryAfter);
|
||||
}
|
||||
|
||||
const lowerBody = toLowerText(body);
|
||||
const minSecMatch = lowerBody.match(/try again in\s+(\d+)m\s*(\d+)s/);
|
||||
if (minSecMatch) {
|
||||
return Number(minSecMatch[1]) * 60 + Number(minSecMatch[2]);
|
||||
}
|
||||
|
||||
const secMatch = lowerBody.match(/(?:try again in|backoff for|wait)\s*(\d+)s/);
|
||||
if (secMatch) {
|
||||
return Number(secMatch[1]);
|
||||
}
|
||||
|
||||
const resetMatch = lowerBody.match(/quota will reset in\s*(\d+)\s*second/);
|
||||
if (resetMatch) {
|
||||
return Number(resetMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private incrementFailureCount(accountId: string): number {
|
||||
const now = Date.now();
|
||||
const entry = this.failureCounts.get(accountId);
|
||||
if (!entry) {
|
||||
this.failureCounts.set(accountId, { count: 1, lastFailureMs: now });
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (now - entry.lastFailureMs > FAILURE_COUNT_EXPIRY_MS) {
|
||||
this.failureCounts.set(accountId, { count: 1, lastFailureMs: now });
|
||||
return 1;
|
||||
}
|
||||
|
||||
const nextCount = entry.count + 1;
|
||||
this.failureCounts.set(accountId, { count: nextCount, lastFailureMs: now });
|
||||
return nextCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import axios from 'axios';
|
||||
import { getAntigravityVersion } from '../../../utils/antigravityVersion';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const REMOTE_VERSION_URL = 'https://antigravity-auto-updater-974169037036.us-central1.run.app';
|
||||
const CHANGELOG_URL = 'https://antigravity.google/changelog';
|
||||
export const FALLBACK_VERSION = '1.22.2';
|
||||
const DEFAULT_REMOTE_TIMEOUT_MS = 2500;
|
||||
const VERSION_REGEX = /\d+\.\d+\.\d+/g;
|
||||
|
||||
type UserAgentSource = 'local' | 'remote' | 'changelog' | 'fallback';
|
||||
|
||||
interface UserAgentResolution {
|
||||
source: UserAgentSource;
|
||||
userAgent: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
let cachedUserAgentResolution: UserAgentResolution | null = null;
|
||||
let pendingUserAgentResolution: Promise<UserAgentResolution> | null = null;
|
||||
|
||||
function normalizeNonEmptyString(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function getPlatformTag(): string {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return 'windows';
|
||||
case 'darwin':
|
||||
return 'darwin';
|
||||
default:
|
||||
return 'linux';
|
||||
}
|
||||
}
|
||||
|
||||
function getArchTag(): string {
|
||||
if (process.arch === 'x64') {
|
||||
return 'amd64';
|
||||
}
|
||||
|
||||
if (process.arch === 'arm64') {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
return process.arch;
|
||||
}
|
||||
|
||||
export function buildUserAgent(version: string): string {
|
||||
return `antigravity/${version} ${getPlatformTag()}/${getArchTag()}`;
|
||||
}
|
||||
|
||||
function compareSemverVersions(left: string, right: string): number {
|
||||
const leftParts = left.split('.').map((part) => Number.parseInt(part, 10));
|
||||
const rightParts = right.split('.').map((part) => Number.parseInt(part, 10));
|
||||
const maxLength = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const leftValue = leftParts[index] ?? 0;
|
||||
const rightValue = rightParts[index] ?? 0;
|
||||
|
||||
if (leftValue > rightValue) {
|
||||
return 1;
|
||||
}
|
||||
if (leftValue < rightValue) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractHighestSemver(text: string): string | null {
|
||||
const matches = text.match(VERSION_REGEX);
|
||||
if (!matches || matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let best = matches[0];
|
||||
for (const candidate of matches) {
|
||||
if (compareSemverVersions(candidate, best) > 0) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
function shouldSkipRemoteVersionLookup(): boolean {
|
||||
return process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
|
||||
}
|
||||
|
||||
export function resolveLocalInstalledVersion(): string | null {
|
||||
let localVersionString: string;
|
||||
try {
|
||||
localVersionString = getAntigravityVersion().shortVersion;
|
||||
const rawVersion = normalizeNonEmptyString(localVersionString);
|
||||
if (!rawVersion) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return extractHighestSemver(localVersionString);
|
||||
}
|
||||
|
||||
async function fetchTextPayload(url: string): Promise<string | null> {
|
||||
try {
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const response = await axios.get<string>(url, {
|
||||
timeout: DEFAULT_REMOTE_TIMEOUT_MS,
|
||||
responseType: 'text',
|
||||
headers: {
|
||||
'User-Agent': buildUserAgent(discoveryVersion),
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof response.data === 'string') {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return JSON.stringify(response.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRemoteVersion(): Promise<{
|
||||
source: Extract<UserAgentSource, 'remote' | 'changelog'>;
|
||||
version: string;
|
||||
} | null> {
|
||||
if (shouldSkipRemoteVersionLookup()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remotePayload = await fetchTextPayload(REMOTE_VERSION_URL);
|
||||
if (remotePayload) {
|
||||
const parsed = extractHighestSemver(remotePayload);
|
||||
if (parsed) {
|
||||
return {
|
||||
source: 'remote',
|
||||
version: parsed,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const changelogPayload = await fetchTextPayload(CHANGELOG_URL);
|
||||
if (!changelogPayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = extractHighestSemver(changelogPayload);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'changelog',
|
||||
version: parsed,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveDefaultUserAgentResolution(): Promise<UserAgentResolution> {
|
||||
let bestVersion = FALLBACK_VERSION;
|
||||
let bestSource: UserAgentSource = 'fallback';
|
||||
|
||||
const localVersion = resolveLocalInstalledVersion();
|
||||
if (localVersion && compareSemverVersions(localVersion, bestVersion) > 0) {
|
||||
bestVersion = localVersion;
|
||||
bestSource = 'local';
|
||||
}
|
||||
|
||||
const remoteVersion = await resolveRemoteVersion();
|
||||
if (remoteVersion && compareSemverVersions(remoteVersion.version, bestVersion) > 0) {
|
||||
bestVersion = remoteVersion.version;
|
||||
bestSource = remoteVersion.source;
|
||||
}
|
||||
|
||||
return {
|
||||
source: bestSource,
|
||||
version: bestVersion,
|
||||
userAgent: buildUserAgent(bestVersion),
|
||||
};
|
||||
}
|
||||
|
||||
async function getResolvedDefaultRequestUserAgent(): Promise<string> {
|
||||
if (cachedUserAgentResolution) {
|
||||
return cachedUserAgentResolution.userAgent;
|
||||
}
|
||||
|
||||
if (pendingUserAgentResolution) {
|
||||
return (await pendingUserAgentResolution).userAgent;
|
||||
}
|
||||
|
||||
pendingUserAgentResolution = resolveDefaultUserAgentResolution()
|
||||
.then((resolved) => {
|
||||
cachedUserAgentResolution = resolved;
|
||||
logger.info(
|
||||
`Default request User-Agent resolved (source=${resolved.source}, version=${resolved.version}): ${resolved.userAgent}`,
|
||||
);
|
||||
return resolved;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingUserAgentResolution = null;
|
||||
});
|
||||
|
||||
return (await pendingUserAgentResolution).userAgent;
|
||||
}
|
||||
|
||||
export async function resolveRequestUserAgent(): Promise<string> {
|
||||
return await getResolvedDefaultRequestUserAgent();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,18 @@
|
||||
import { Notification } from 'electron';
|
||||
import { CloudAccountRepo } from '../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from './GoogleAPIService';
|
||||
import { AutoSwitchService } from './AutoSwitchService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { classifyAccountStatusFromError } from '../utils/account-status';
|
||||
|
||||
function hasReusableCachedQuota(account: {
|
||||
quota?: { models?: Record<string, unknown> };
|
||||
}): boolean {
|
||||
if (!account.quota || !account.quota.models) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(account.quota.models).length > 0;
|
||||
}
|
||||
|
||||
export class CloudMonitorService {
|
||||
private static intervalId: NodeJS.Timeout | null = null;
|
||||
@@ -101,29 +112,91 @@ export class CloudMonitorService {
|
||||
let accessToken = account.token.access_token;
|
||||
if (account.token.expiry_timestamp < now + 600) {
|
||||
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
||||
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
||||
account.token.access_token = newToken.access_token;
|
||||
account.token.expires_in = newToken.expires_in;
|
||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
accessToken = newToken.access_token;
|
||||
try {
|
||||
const newToken = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
account.token.access_token = newToken.access_token;
|
||||
account.token.expires_in = newToken.expires_in;
|
||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newToken.oauth_client_key,
|
||||
);
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
accessToken = newToken.access_token;
|
||||
} catch (refreshError) {
|
||||
logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError);
|
||||
const classified = classifyAccountStatusFromError(refreshError);
|
||||
if (classified) {
|
||||
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Quota
|
||||
// We delay slightly between requests to act human/avoid spike
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
const quota = await GoogleAPIService.fetchQuota(accessToken);
|
||||
const quota = await GoogleAPIService.fetchQuota(accessToken, account.proxy_url);
|
||||
const previousAICredits = account.quota?.ai_credits;
|
||||
|
||||
try {
|
||||
const aiCredits = await GoogleAPIService.fetchAICredits(accessToken, account.proxy_url);
|
||||
if (aiCredits) {
|
||||
quota.ai_credits = aiCredits;
|
||||
} else if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
} catch (creditError) {
|
||||
logger.warn(`Monitor: Failed to fetch credits for ${account.email}`, creditError);
|
||||
if (previousAICredits) {
|
||||
quota.ai_credits = previousAICredits;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update DB
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
|
||||
} catch (error) {
|
||||
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
||||
// Could mark status as 'error' or 'rate_limited' if 429
|
||||
const classified = classifyAccountStatusFromError(error);
|
||||
if (classified) {
|
||||
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
|
||||
if (classified.status === 'rate_limited' && hasReusableCachedQuota(account)) {
|
||||
logger.warn(
|
||||
`Monitor: Quota request rate-limited for ${account.email}, keeping cached quota as fallback.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check for Auto-Switch
|
||||
// 4. Check for Quota Alerts
|
||||
const alertEnabled = CloudAccountRepo.getSetting<boolean>('quota_alert_enabled', false);
|
||||
const alertThreshold = CloudAccountRepo.getSetting<number>('quota_alert_threshold', 20);
|
||||
|
||||
if (alertEnabled) {
|
||||
for (const account of accounts) {
|
||||
if (!account.quota?.models) continue;
|
||||
const lowQuotaModels = Object.entries(account.quota.models)
|
||||
.filter(([_, info]) => info.percentage <= alertThreshold && info.percentage > 0)
|
||||
.map(([name, info]) => {
|
||||
return info.display_name || name.replace('models/', '').replace(/-/g, ' ');
|
||||
});
|
||||
|
||||
if (lowQuotaModels.length > 0) {
|
||||
new Notification({
|
||||
title: 'Low Quota Alert',
|
||||
body: `${account.email}: ${lowQuotaModels.join(', ')} are low on quota`,
|
||||
silent: false,
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check for Auto-Switch
|
||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
|
||||
+792
-131
File diff suppressed because it is too large.
Load diff
@@ -79,7 +79,7 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
||||
const requestWithTools = {
|
||||
...BASE_REQUEST,
|
||||
// Maps to a gemini-3 model potentially
|
||||
model: 'gemini-3-pro-preview',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tools: [{ name: 'get_weather' }], // Mock tool structure
|
||||
thinking: { type: 'enabled', budget_tokens: 1000 },
|
||||
} as any;
|
||||
@@ -96,7 +96,7 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
||||
|
||||
const requestWithTools = {
|
||||
...BASE_REQUEST,
|
||||
model: 'gemini-3-pro-preview',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tools: [{ name: 'get_weather' }],
|
||||
thinking: { type: 'enabled', budget_tokens: 1000 },
|
||||
} as any;
|
||||
@@ -110,7 +110,7 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
||||
it('should KEEP thinking mode if NO function calls exist (pure thinking)', () => {
|
||||
const requestPureThinking = {
|
||||
...BASE_REQUEST,
|
||||
model: 'gemini-3-pro-preview',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
// No tools
|
||||
thinking: { type: 'enabled', budget_tokens: 1000 },
|
||||
} as any;
|
||||
@@ -120,5 +120,60 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
||||
// Should keep thinking config
|
||||
expect(result.request.generationConfig?.thinkingConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it('should auto-enable thinking for gemini-3-flash even without explicit thinking config', () => {
|
||||
const result = transformClaudeRequestIn(
|
||||
{
|
||||
...BASE_REQUEST,
|
||||
model: 'gemini-3-flash',
|
||||
thinking: undefined,
|
||||
} as any,
|
||||
'test-project',
|
||||
);
|
||||
|
||||
expect(result.request.generationConfig?.thinkingConfig).toBeDefined();
|
||||
expect(result.request.generationConfig?.thinkingConfig?.includeThoughts).toBe(true);
|
||||
});
|
||||
|
||||
it('should inject flash sentinel thoughtSignature for tool_use without cached signature', () => {
|
||||
const requestWithToolUse = {
|
||||
...BASE_REQUEST,
|
||||
model: 'gemini-3-flash',
|
||||
thinking: undefined,
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_1',
|
||||
name: 'get_weather',
|
||||
input: { location: 'Beijing' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const result = transformClaudeRequestIn(requestWithToolUse, 'test-project');
|
||||
const part = result.request.contents?.[0]?.parts?.[0] as any;
|
||||
expect(part?.thoughtSignature).toBe('skip_thought_signature_validator');
|
||||
});
|
||||
|
||||
it('should keep mixed tools for gemini 2.x+ when both function and web search are requested', () => {
|
||||
const mixedToolsRequest = {
|
||||
...BASE_REQUEST,
|
||||
model: 'gemini-2.0-flash-online',
|
||||
tools: [{ name: 'get_weather' }, { name: 'web_search' }],
|
||||
} as any;
|
||||
|
||||
const result = transformClaudeRequestIn(mixedToolsRequest, 'test-project');
|
||||
const tools = result.request.tools || [];
|
||||
const hasFunctions = tools.some((tool: any) => Array.isArray(tool.functionDeclarations));
|
||||
const hasGoogleSearch = tools.some((tool: any) => tool.googleSearch);
|
||||
|
||||
expect(hasFunctions).toBe(true);
|
||||
expect(hasGoogleSearch).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ const mockTokenManager: any = {
|
||||
getNextToken: async () => null,
|
||||
markAsRateLimited: () => undefined,
|
||||
markAsForbidden: () => undefined,
|
||||
markFromUpstreamError: () => undefined,
|
||||
recordParityError: () => undefined,
|
||||
};
|
||||
|
||||
const mockGeminiClient: any = {
|
||||
@@ -225,7 +227,11 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
||||
await realTokenManager.onModuleInit();
|
||||
|
||||
const tokenManagerProxy = {
|
||||
getNextToken: async (options?: { sessionKey?: string; excludeAccountIds?: string[] }) => {
|
||||
getNextToken: async (options?: {
|
||||
sessionKey?: string;
|
||||
excludeAccountIds?: string[];
|
||||
model?: string;
|
||||
}) => {
|
||||
observedGetNextTokenOptions = options ?? null;
|
||||
selectedToken = await realTokenManager.getNextToken(options);
|
||||
return selectedToken;
|
||||
@@ -234,6 +240,14 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
||||
realTokenManager.markAsRateLimited(accountIdOrEmail),
|
||||
markAsForbidden: (accountIdOrEmail: string) =>
|
||||
realTokenManager.markAsForbidden(accountIdOrEmail),
|
||||
markFromUpstreamError: (args: {
|
||||
accountIdOrEmail: string;
|
||||
status?: number;
|
||||
retryAfter?: string;
|
||||
body?: string;
|
||||
model?: string;
|
||||
}) => realTokenManager.markFromUpstreamError(args),
|
||||
recordParityError: () => realTokenManager.recordParityError(),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('CloudMonitorService', () => {
|
||||
await pollPromise;
|
||||
|
||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token');
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token', undefined);
|
||||
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
|
||||
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
||||
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
||||
@@ -93,9 +93,13 @@ describe('CloudMonitorService', () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await pollPromise;
|
||||
|
||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
|
||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'ref_token',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token', undefined);
|
||||
});
|
||||
|
||||
describe('handleAppFocus (Smart Refresh)', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ProtobufUtils } from '../../utils/protobuf';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import type { UserInfo } from '../../services/GoogleAPIService';
|
||||
import { toSyncLocalAccountORPCError } from '../../ipc/cloud/router';
|
||||
|
||||
let mockData: Record<string, string>;
|
||||
let busyOnFirstGet = false;
|
||||
@@ -168,6 +169,34 @@ describe('CloudAccountRepo.syncFromIDE', () => {
|
||||
expect(account?.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('reads enterprise project preference from IDE unified state when syncing new account', async () => {
|
||||
const accessToken = 'access-enterprise';
|
||||
const refreshToken = 'refresh-enterprise';
|
||||
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(accessToken, refreshToken, 1700000000);
|
||||
const projectPayload = ProtobufUtils.createStringValuePayload('enterprise-project-1');
|
||||
const enterprisePreferenceB64 = ProtobufUtils.createUnifiedStateEntry(
|
||||
'enterpriseGcpProjectId',
|
||||
projectPayload,
|
||||
);
|
||||
|
||||
mockData['antigravityUnifiedStateSync.oauthToken'] = unifiedB64;
|
||||
mockData['antigravityUnifiedStateSync.enterprisePreferences'] = enterprisePreferenceB64;
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
|
||||
createMockUserInfo('[email protected]', 'Enterprise User'),
|
||||
);
|
||||
|
||||
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([]);
|
||||
vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
|
||||
|
||||
const account = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
expect(GoogleAPIService.getUserInfo).toHaveBeenCalledWith(accessToken);
|
||||
expect(account?.email).toBe('[email protected]');
|
||||
expect(account?.token.project_id).toBe('enterprise-project-1');
|
||||
});
|
||||
|
||||
it('should fall back to old oauth token when unified is missing', async () => {
|
||||
const accessToken = 'access-old';
|
||||
const refreshToken = 'refresh-old';
|
||||
@@ -191,6 +220,191 @@ describe('CloudAccountRepo.syncFromIDE', () => {
|
||||
expect(account?.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('preserves existing token metadata and proxy settings when syncing existing account', async () => {
|
||||
const accessToken = 'access-updated';
|
||||
const refreshToken = 'refresh-updated';
|
||||
const oldB64 = Buffer.from(
|
||||
ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000),
|
||||
).toString('base64');
|
||||
|
||||
mockData['jetskiStateSync.agentManagerInitState'] = oldB64;
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
|
||||
createMockUserInfo('[email protected]', 'Existing User'),
|
||||
);
|
||||
|
||||
const existingAccount = {
|
||||
id: 'existing-id',
|
||||
provider: 'google' as const,
|
||||
email: '[email protected]',
|
||||
name: 'Existing User',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
token: {
|
||||
access_token: 'old-access',
|
||||
refresh_token: 'old-refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: 1699999999,
|
||||
token_type: 'Bearer',
|
||||
email: '[email protected]',
|
||||
project_id: 'project-keep',
|
||||
oauth_client_key: 'custom-client',
|
||||
session_id: 'session-keep',
|
||||
upstream_proxy_url: 'http://127.0.0.1:8080',
|
||||
},
|
||||
quota: undefined,
|
||||
device_profile: undefined,
|
||||
device_history: undefined,
|
||||
created_at: 1690000000,
|
||||
last_used: 1690000100,
|
||||
status: 'active' as const,
|
||||
is_active: false,
|
||||
proxy_url: 'http://127.0.0.1:7890',
|
||||
};
|
||||
|
||||
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([existingAccount]);
|
||||
const addAccountSpy = vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
|
||||
|
||||
const account = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
expect(account?.id).toBe('existing-id');
|
||||
expect(account?.token.project_id).toBe('project-keep');
|
||||
expect(account?.token.oauth_client_key).toBe('custom-client');
|
||||
expect(account?.token.session_id).toBe('session-keep');
|
||||
expect(account?.token.upstream_proxy_url).toBe('http://127.0.0.1:8080');
|
||||
expect(account?.proxy_url).toBe('http://127.0.0.1:7890');
|
||||
|
||||
expect(addAccountSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'existing-id',
|
||||
token: expect.objectContaining({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
project_id: 'project-keep',
|
||||
oauth_client_key: 'custom-client',
|
||||
}),
|
||||
proxy_url: 'http://127.0.0.1:7890',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should recover project_id from IDE enterprise preferences when existing project_id is blank', async () => {
|
||||
const accessToken = 'access-blank-project';
|
||||
const refreshToken = 'refresh-blank-project';
|
||||
const unifiedB64 = ProtobufUtils.createUnifiedOAuthToken(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
1700000000,
|
||||
true,
|
||||
);
|
||||
const enterprisePreferenceB64 = ProtobufUtils.createUnifiedStateEntry(
|
||||
'enterpriseGcpProjectId',
|
||||
ProtobufUtils.createStringValuePayload('enterprise-project-recovered'),
|
||||
);
|
||||
|
||||
mockData['antigravityUnifiedStateSync.oauthToken'] = unifiedB64;
|
||||
mockData['antigravityUnifiedStateSync.enterprisePreferences'] = enterprisePreferenceB64;
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
|
||||
createMockUserInfo('[email protected]', 'Existing User'),
|
||||
);
|
||||
|
||||
const existingAccount = {
|
||||
id: 'existing-id',
|
||||
provider: 'google' as const,
|
||||
email: '[email protected]',
|
||||
name: 'Existing User',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
token: {
|
||||
access_token: 'old-access',
|
||||
refresh_token: 'old-refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: 1699999999,
|
||||
token_type: 'Bearer',
|
||||
email: '[email protected]',
|
||||
project_id: ' ',
|
||||
oauth_client_key: 'custom-client',
|
||||
},
|
||||
quota: undefined,
|
||||
device_profile: undefined,
|
||||
device_history: undefined,
|
||||
created_at: 1690000000,
|
||||
last_used: 1690000100,
|
||||
status: 'active' as const,
|
||||
is_active: false,
|
||||
proxy_url: 'http://127.0.0.1:7890',
|
||||
};
|
||||
|
||||
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([existingAccount]);
|
||||
const addAccountSpy = vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
|
||||
|
||||
const account = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
expect(account?.token.project_id).toBe('enterprise-project-recovered');
|
||||
expect(addAccountSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: expect.objectContaining({
|
||||
project_id: 'enterprise-project-recovered',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset stale blocked status after successful IDE resync', async () => {
|
||||
const accessToken = 'access-status-reset';
|
||||
const refreshToken = 'refresh-status-reset';
|
||||
const oldB64 = Buffer.from(
|
||||
ProtobufUtils.createOAuthTokenInfo(accessToken, refreshToken, 1700000000),
|
||||
).toString('base64');
|
||||
|
||||
mockData['jetskiStateSync.agentManagerInitState'] = oldB64;
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.mocked(GoogleAPIService.getUserInfo).mockResolvedValue(
|
||||
createMockUserInfo('[email protected]', 'Existing User'),
|
||||
);
|
||||
|
||||
const existingAccount = {
|
||||
id: 'existing-id',
|
||||
provider: 'google' as const,
|
||||
email: '[email protected]',
|
||||
name: 'Existing User',
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
token: {
|
||||
access_token: 'old-access',
|
||||
refresh_token: 'old-refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: 1699999999,
|
||||
token_type: 'Bearer',
|
||||
email: '[email protected]',
|
||||
},
|
||||
quota: undefined,
|
||||
device_profile: undefined,
|
||||
device_history: undefined,
|
||||
created_at: 1690000000,
|
||||
last_used: 1690000100,
|
||||
status: 'rate_limited' as const,
|
||||
status_reason: 'RESOURCE_EXHAUSTED',
|
||||
is_active: false,
|
||||
proxy_url: 'http://127.0.0.1:7890',
|
||||
};
|
||||
|
||||
vi.spyOn(CloudAccountRepo, 'getAccounts').mockResolvedValue([existingAccount]);
|
||||
const addAccountSpy = vi.spyOn(CloudAccountRepo, 'addAccount').mockResolvedValue();
|
||||
|
||||
const account = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
expect(account?.status).toBe('active');
|
||||
expect(account?.status_reason).toBeUndefined();
|
||||
expect(addAccountSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'active',
|
||||
status_reason: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry when sqlite is busy', async () => {
|
||||
busyOnFirstGet = true;
|
||||
const accessToken = 'access-retry';
|
||||
@@ -268,6 +482,20 @@ describe('CloudAccountRepo.syncFromIDE', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncLocalAccount ORPC error mapping', () => {
|
||||
it('preserves re-login guidance as an actionable unauthorized error', () => {
|
||||
const error = toSyncLocalAccountORPCError(
|
||||
new Error(
|
||||
'Failed to validate token with Google API. The token may be expired. Please re-login in Antigravity IDE.',
|
||||
),
|
||||
);
|
||||
|
||||
expect(error.code).toBe('UNAUTHORIZED');
|
||||
expect(error.status).toBe(401);
|
||||
expect(error.message).toContain('Please re-login in Antigravity IDE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud switch fail-fast path', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
@@ -386,3 +614,172 @@ describe('cloud switch fail-fast path', () => {
|
||||
expect(recordSwitchSuccessMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cloud oauth client key backfill', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('backfills missing oauth_client_key with active non-enterprise client', async () => {
|
||||
const updateTokenMock = vi.fn(async () => undefined);
|
||||
const setSettingMock = vi.fn();
|
||||
const accounts = [
|
||||
{
|
||||
id: 'acc-1',
|
||||
provider: 'google' as const,
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'access',
|
||||
refresh_token: 'refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: '[email protected]',
|
||||
},
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
last_used: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
];
|
||||
|
||||
vi.doMock('../../ipc/database/cloudHandler', () => ({
|
||||
CloudAccountRepo: {
|
||||
getAccounts: vi.fn(async () => accounts),
|
||||
updateToken: updateTokenMock,
|
||||
getSetting: vi.fn((key: string, defaultValue: unknown) => {
|
||||
if (key === 'oauth_client_key_backfill_v1_done') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'active_oauth_client_key') {
|
||||
return 'custom_a';
|
||||
}
|
||||
return defaultValue;
|
||||
}),
|
||||
setSetting: setSettingMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const setActiveOAuthClientKeyMock = vi.fn();
|
||||
const getActiveOAuthClientKeyMock = vi.fn(() => 'custom_a');
|
||||
vi.doMock('../../services/GoogleAPIService', () => ({
|
||||
GoogleAPIService: {
|
||||
setActiveOAuthClientKey: setActiveOAuthClientKeyMock,
|
||||
getActiveOAuthClientKey: getActiveOAuthClientKeyMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('../../utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../ipc/tray/handler', () => ({ updateTrayMenu: vi.fn() }));
|
||||
vi.doMock('../../ipc/device/handler', () => ({
|
||||
ensureGlobalOriginalFromCurrentStorage: vi.fn(),
|
||||
generateDeviceProfile: vi.fn(),
|
||||
getStorageDirectoryPath: vi.fn(() => ''),
|
||||
isIdentityProfileApplyEnabled: vi.fn(() => false),
|
||||
loadGlobalOriginalProfile: vi.fn(),
|
||||
readCurrentDeviceProfile: vi.fn(),
|
||||
saveGlobalOriginalProfile: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../../utils/paths', () => ({ getAntigravityDbPaths: () => [] }));
|
||||
vi.doMock('../../ipc/switchGuard', () => ({
|
||||
runWithSwitchGuard: async (_owner: string, fn: () => Promise<void>) => fn(),
|
||||
}));
|
||||
vi.doMock('../../ipc/switchFlow', () => ({ executeSwitchFlow: vi.fn() }));
|
||||
vi.doMock('electron', () => ({ shell: { openExternal: vi.fn() } }));
|
||||
|
||||
const { listCloudAccounts } = await import('../../ipc/cloud/handler');
|
||||
await listCloudAccounts();
|
||||
|
||||
expect(setActiveOAuthClientKeyMock).toHaveBeenCalledWith('custom_a');
|
||||
expect(updateTokenMock).toHaveBeenCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({
|
||||
oauth_client_key: 'custom_a',
|
||||
}),
|
||||
);
|
||||
expect(setSettingMock).toHaveBeenCalledWith('oauth_client_key_backfill_v1_done', true);
|
||||
});
|
||||
|
||||
it('skips enterprise backfill for legacy account without project_id', async () => {
|
||||
const updateTokenMock = vi.fn(async () => undefined);
|
||||
const setSettingMock = vi.fn();
|
||||
const accounts = [
|
||||
{
|
||||
id: 'acc-legacy',
|
||||
provider: 'google' as const,
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'access',
|
||||
refresh_token: 'refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
token_type: 'Bearer',
|
||||
email: '[email protected]',
|
||||
},
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
last_used: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
];
|
||||
|
||||
vi.doMock('../../ipc/database/cloudHandler', () => ({
|
||||
CloudAccountRepo: {
|
||||
getAccounts: vi.fn(async () => accounts),
|
||||
updateToken: updateTokenMock,
|
||||
getSetting: vi.fn((key: string, defaultValue: unknown) => {
|
||||
if (key === 'oauth_client_key_backfill_v1_done') {
|
||||
return false;
|
||||
}
|
||||
if (key === 'active_oauth_client_key') {
|
||||
return 'antigravity_enterprise';
|
||||
}
|
||||
return defaultValue;
|
||||
}),
|
||||
setSetting: setSettingMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('../../services/GoogleAPIService', () => ({
|
||||
GoogleAPIService: {
|
||||
setActiveOAuthClientKey: vi.fn(),
|
||||
getActiveOAuthClientKey: vi.fn(() => 'antigravity_enterprise'),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock('../../utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.doMock('../../ipc/tray/handler', () => ({ updateTrayMenu: vi.fn() }));
|
||||
vi.doMock('../../ipc/device/handler', () => ({
|
||||
ensureGlobalOriginalFromCurrentStorage: vi.fn(),
|
||||
generateDeviceProfile: vi.fn(),
|
||||
getStorageDirectoryPath: vi.fn(() => ''),
|
||||
isIdentityProfileApplyEnabled: vi.fn(() => false),
|
||||
loadGlobalOriginalProfile: vi.fn(),
|
||||
readCurrentDeviceProfile: vi.fn(),
|
||||
saveGlobalOriginalProfile: vi.fn(),
|
||||
}));
|
||||
vi.doMock('../../utils/paths', () => ({ getAntigravityDbPaths: () => [] }));
|
||||
vi.doMock('../../ipc/switchGuard', () => ({
|
||||
runWithSwitchGuard: async (_owner: string, fn: () => Promise<void>) => fn(),
|
||||
}));
|
||||
vi.doMock('../../ipc/switchFlow', () => ({ executeSwitchFlow: vi.fn() }));
|
||||
vi.doMock('electron', () => ({ shell: { openExternal: vi.fn() } }));
|
||||
|
||||
const { listCloudAccounts } = await import('../../ipc/cloud/handler');
|
||||
await listCloudAccounts();
|
||||
|
||||
expect(updateTokenMock).not.toHaveBeenCalled();
|
||||
expect(setSettingMock).toHaveBeenCalledWith('oauth_client_key_backfill_v1_done', true);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,14 @@ describe('GeminiController Integration', () => {
|
||||
expect.objectContaining({
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'models/gemini-2.5-flash',
|
||||
name: 'models/gemini-3-flash',
|
||||
description: '',
|
||||
inputTokenLimit: 128000,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'countTokens'],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'models/gemini-3.1-pro-high',
|
||||
description: '',
|
||||
inputTokenLimit: 128000,
|
||||
outputTokenLimit: 8192,
|
||||
@@ -74,13 +81,13 @@ describe('GeminiController Integration', () => {
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.modelAction(
|
||||
'models/gemini-2.5-flash:generateContent',
|
||||
'models/gemini-3.1-pro-high:generateContent',
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledWith(
|
||||
'models/gemini-2.5-flash',
|
||||
'models/gemini-3.1-pro-high',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
|
||||
@@ -80,3 +80,340 @@ describe('GoogleAPIService Timeout', () => {
|
||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService OAuth clients', () => {
|
||||
const originalOauthClientsEnv = process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
const originalActiveOauthClientEnv = process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (typeof originalOauthClientsEnv === 'string') {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||
}
|
||||
|
||||
if (typeof originalActiveOauthClientEnv === 'string') {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv;
|
||||
} else {
|
||||
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('loads builtin and custom oauth clients with active marker', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS =
|
||||
'custom_a|id-a|secret-a|Custom A;custom_b|id-b|secret-b|Custom B';
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = 'custom_b';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
const clients = GoogleAPIService.listOAuthClients();
|
||||
|
||||
expect(clients.find((client) => client.key === 'antigravity_enterprise')).toBeDefined();
|
||||
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
|
||||
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
|
||||
});
|
||||
|
||||
it('switches active oauth client key', async () => {
|
||||
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom_a|id-a|secret-a|Custom A';
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
GoogleAPIService.setActiveOAuthClientKey('custom_a');
|
||||
|
||||
expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('custom_a');
|
||||
});
|
||||
|
||||
it('throws when switching to unknown oauth client key', async () => {
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
expect(() => GoogleAPIService.setActiveOAuthClientKey('missing_client')).toThrow(
|
||||
'Unknown OAuth client key',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService user info parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts Google user info responses without family_name', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
id: 'google-user-1',
|
||||
email: '[email protected]',
|
||||
verified_email: true,
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.getUserInfo('access-token')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'google-user-1',
|
||||
email: '[email protected]',
|
||||
name: 'Example User',
|
||||
family_name: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloudAccountList auth code auto-submit guard', () => {
|
||||
it('returns true for a fresh auth code while the dialog is open', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'fresh-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: null,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false after the same auth code was already auto-submitted', async () => {
|
||||
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
|
||||
|
||||
expect(
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode: 'same-code',
|
||||
isAddDialogOpen: true,
|
||||
isPending: false,
|
||||
lastSubmittedAuthCode: 'same-code',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoogleAPIService fetchQuota fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('falls back to loadCodeAssist credits when fetchCredits returns 404', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: vi.fn().mockResolvedValue('NOT_FOUND'),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
paidTier: {
|
||||
availableCredits: [
|
||||
{
|
||||
creditType: 'GOOGLE_ONE_AI',
|
||||
creditAmount: '1000',
|
||||
minimumCreditAmountForUsage: '50',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
const { FALLBACK_VERSION, resolveLocalInstalledVersion } = await import(
|
||||
'../../server/modules/proxy/request-user-agent'
|
||||
);
|
||||
const expectedVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('POST');
|
||||
expect(fetchMock.mock.calls[1]?.[1]?.body).toBe(
|
||||
JSON.stringify({
|
||||
metadata: {
|
||||
ide_type: 'ANTIGRAVITY',
|
||||
ide_version: expectedVersion,
|
||||
ide_name: 'antigravity',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
vi.spyOn(GoogleAPIService, 'fetchProjectContext').mockResolvedValue({
|
||||
projectId: 'project-1',
|
||||
subscriptionTier: 'free',
|
||||
});
|
||||
|
||||
await expect(GoogleAPIService.fetchQuota('access-token')).rejects.toThrow(
|
||||
'HTTP 400 - INVALID_ARGUMENT',
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses an explicit dispatcher from env proxy settings when no account proxy is provided', async () => {
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.HTTPS_PROXY = 'http://127.0.0.1:9090';
|
||||
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.NO_PROXY;
|
||||
});
|
||||
|
||||
it('uses electron proxy env as an explicit dispatcher fallback', async () => {
|
||||
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const { ConfigManager } = await import('../../ipc/config/manager');
|
||||
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
|
||||
proxy: {
|
||||
upstream_proxy: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
|
||||
|
||||
delete process.env.ELECTRON_PROXY_SERVER;
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuotaService fallback policy', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
|
||||
const postMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
cloudaicompanionProject: 'project-1',
|
||||
currentTier: { id: 'free' },
|
||||
},
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 400,
|
||||
data: { error: 'INVALID_ARGUMENT' },
|
||||
},
|
||||
message: 'Request failed with status code 400',
|
||||
});
|
||||
|
||||
vi.doMock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(() => ({
|
||||
post: postMock,
|
||||
})),
|
||||
isAxiosError: (error: unknown) => Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
},
|
||||
isAxiosError: (error: unknown) => Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
|
||||
}));
|
||||
|
||||
const { QuotaService } = await import('../../lib/antigravity/QuotaService');
|
||||
|
||||
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
|
||||
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DEFAULT_APP_CONFIG, ProxyConfig } from '../../types/config';
|
||||
import { setServerConfig } from '../../server/server-config';
|
||||
import { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
|
||||
function createProxyConfig(overrides: Partial<ProxyConfig>): ProxyConfig {
|
||||
return {
|
||||
...DEFAULT_APP_CONFIG.proxy,
|
||||
...overrides,
|
||||
upstream_proxy: {
|
||||
...DEFAULT_APP_CONFIG.proxy.upstream_proxy,
|
||||
...(overrides.upstream_proxy ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedTokens(service: TokenManagerService): void {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
(service as any).tokens = new Map([
|
||||
[
|
||||
'acc-1',
|
||||
{
|
||||
account_id: 'acc-1',
|
||||
email: '[email protected]',
|
||||
access_token: 'token-1',
|
||||
refresh_token: 'refresh-1',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: nowSec + 3600,
|
||||
project_id: 'project-1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'acc-2',
|
||||
{
|
||||
account_id: 'acc-2',
|
||||
email: '[email protected]',
|
||||
access_token: 'token-2',
|
||||
refresh_token: 'refresh-2',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: nowSec + 3600,
|
||||
project_id: 'project-2',
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
describe('Parity rollout guard replay', () => {
|
||||
let service: TokenManagerService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TokenManagerService();
|
||||
seedTokens(service);
|
||||
});
|
||||
|
||||
it('supports kill-switch rollback from parity path to baseline path', async () => {
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: true,
|
||||
parity_kill_switch: false,
|
||||
preferred_account_id: 'acc-2',
|
||||
}),
|
||||
);
|
||||
const paritySelected = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
expect(paritySelected?.id).toBe('acc-2');
|
||||
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: true,
|
||||
parity_kill_switch: true,
|
||||
preferred_account_id: 'acc-2',
|
||||
}),
|
||||
);
|
||||
service.resetSelectionState();
|
||||
const rollbackSelected = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
expect(rollbackSelected?.id).toBe('acc-1');
|
||||
// Verify that shadow comparison was bypassed due to kill-switch
|
||||
expect((service as any).shadowComparisonCount).toBe(0);
|
||||
});
|
||||
|
||||
it('enforces no-go threshold after shadow mismatch and blocks parity enablement', async () => {
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: false,
|
||||
parity_shadow_enabled: true,
|
||||
parity_no_go_mismatch_rate: 0,
|
||||
preferred_account_id: 'acc-2',
|
||||
}),
|
||||
);
|
||||
|
||||
const shadowSelection = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
expect(shadowSelection?.id).toBe('acc-1');
|
||||
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: true,
|
||||
parity_shadow_enabled: false,
|
||||
parity_kill_switch: false,
|
||||
preferred_account_id: 'acc-2',
|
||||
}),
|
||||
);
|
||||
service.resetSelectionState();
|
||||
const blockedSelection = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
expect(blockedSelection?.id).toBe('acc-1');
|
||||
expect((service as any).noGoBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -30,4 +30,36 @@ describe('ProtobufUtils Unified OAuth', () => {
|
||||
refreshToken,
|
||||
});
|
||||
});
|
||||
|
||||
it('should round-trip unified state entry with topic/row structure', () => {
|
||||
const payload = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const entry = ProtobufUtils.createUnifiedStateEntry('userStatusSentinelKey', payload);
|
||||
const decoded = ProtobufUtils.decodeUnifiedStateEntry(entry);
|
||||
|
||||
expect(decoded.sentinelKey).toBe('userStatusSentinelKey');
|
||||
expect(Array.from(decoded.payload)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('should parse legacy nested unified oauth payload', () => {
|
||||
const accessToken = 'legacy-access';
|
||||
const refreshToken = 'legacy-refresh';
|
||||
const oauthInfo = ProtobufUtils.createOAuthInfo(accessToken, refreshToken, 1700000000);
|
||||
const oauthInfoB64 = Buffer.from(oauthInfo).toString('base64');
|
||||
|
||||
// Legacy nested structure:
|
||||
// Outer(F1) -> Inner(F1 sentinel, F2 Inner2), Inner2(F1 base64(oauthInfo))
|
||||
const inner2 = ProtobufUtils.encodeStringField(1, oauthInfoB64);
|
||||
const inner = new Uint8Array(
|
||||
ProtobufUtils.encodeStringField(1, 'oauthTokenInfoSentinelKey').length +
|
||||
ProtobufUtils.encodeLenDelimField(2, inner2).length,
|
||||
);
|
||||
const innerField1 = ProtobufUtils.encodeStringField(1, 'oauthTokenInfoSentinelKey');
|
||||
const innerField2 = ProtobufUtils.encodeLenDelimField(2, inner2);
|
||||
inner.set(innerField1, 0);
|
||||
inner.set(innerField2, innerField1.length);
|
||||
const legacyOuter = ProtobufUtils.encodeLenDelimField(1, inner);
|
||||
|
||||
const parsed = ProtobufUtils.extractOAuthTokenInfoFromUnifiedState(legacyOuter);
|
||||
expect(parsed).toEqual({ accessToken, refreshToken });
|
||||
});
|
||||
});
|
||||
@@ -200,6 +200,77 @@ describe('ProxyController Integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('supports OpenAI responses compatibility endpoint in stream mode with SSE headers', async () => {
|
||||
const stream = of('data: {"id":"chatcmpl_resp_stream"}\n\n');
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue(stream),
|
||||
};
|
||||
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.responses(
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
instructions: 'stream output',
|
||||
input: 'hello',
|
||||
stream: true,
|
||||
},
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'text/event-stream');
|
||||
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
||||
expect(reply.header).toHaveBeenCalledWith('Connection', 'keep-alive');
|
||||
expect(reply.send).toHaveBeenCalledWith(stream);
|
||||
});
|
||||
|
||||
it('normalizes web_search_call in /v1/responses into builtin_web_search tool messages', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||
id: 'chatcmpl_resp_search',
|
||||
object: 'chat.completion',
|
||||
created: 1700000002,
|
||||
model: 'gpt-4o',
|
||||
choices: [{ index: 0, finish_reason: 'stop', message: { content: 'done' } }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.responses(
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
input: [
|
||||
{
|
||||
type: 'web_search_call',
|
||||
call_id: 'call_search_1',
|
||||
action: { query: 'gemini api' },
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_search_1',
|
||||
output: { content: 'search result' },
|
||||
},
|
||||
],
|
||||
},
|
||||
reply as any,
|
||||
);
|
||||
|
||||
const callArg = proxyService.handleChatCompletions.mock.calls[0][0];
|
||||
const assistantMessage = callArg.messages.find(
|
||||
(message: { role: string }) => message.role === 'assistant',
|
||||
);
|
||||
const toolMessage = callArg.messages.find(
|
||||
(message: { role: string }) => message.role === 'tool',
|
||||
);
|
||||
|
||||
expect(assistantMessage?.tool_calls?.[0]?.function?.name).toBe('builtin_web_search');
|
||||
expect(toolMessage?.name).toBe('builtin_web_search');
|
||||
});
|
||||
|
||||
it('supports image generations endpoint', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -10,6 +10,8 @@ const mockTokenManager = {
|
||||
getNextToken: vi.fn(),
|
||||
markAsRateLimited: vi.fn(),
|
||||
markAsForbidden: vi.fn(),
|
||||
markFromUpstreamError: vi.fn(),
|
||||
recordParityError: vi.fn(),
|
||||
};
|
||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||
|
||||
@@ -95,4 +97,5 @@ describe('Proxy Parity Fixtures', () => {
|
||||
expect(output).toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -8,6 +8,11 @@ const mockTokenManager = {
|
||||
getNextToken: vi.fn(),
|
||||
markAsRateLimited: vi.fn(),
|
||||
markAsForbidden: vi.fn(),
|
||||
markFromUpstreamError: vi.fn(),
|
||||
recordParityError: vi.fn(),
|
||||
getModelOutputLimitForAccount: vi.fn(),
|
||||
getModelThinkingBudgetForAccount: vi.fn(),
|
||||
resolveDynamicModelForAccount: vi.fn((_token: unknown, model: string) => model),
|
||||
};
|
||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||
|
||||
@@ -27,7 +32,7 @@ class TestableProxyService extends ProxyService {
|
||||
}
|
||||
|
||||
public testModelHeaders(model: string): Record<string, string> {
|
||||
return (this as any).buildModelSpecificHeaders(model);
|
||||
return (this as any).createModelSpecificHeaders(model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +59,7 @@ describe('ProxyService Empty Stream Retry Logic', () => {
|
||||
|
||||
it('classifies retry matrix consistently', () => {
|
||||
const service = new TestableProxyService();
|
||||
const classify = (message: string) => (service as any).classifyUpstreamError(message);
|
||||
const classify = (message: string) => (service as any).classifyUpstreamFailure(message);
|
||||
|
||||
expect(classify('401 unauthorized token')).toEqual({
|
||||
retry: true,
|
||||
@@ -401,6 +406,22 @@ describe('ProxyService Empty Stream Retry Logic', () => {
|
||||
expect(internalPayload).not.toHaveProperty('sessionId');
|
||||
});
|
||||
|
||||
it('normalizes Gemini 3.1 preview alias to Gemini 3.1 Pro High for upstream', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
|
||||
await service.handleGeminiGenerateContent('models/gemini-3.1-pro-preview', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
const internalPayload = mockGeminiClient.generateInternal.mock.calls[0][0];
|
||||
expect(internalPayload.model).toBe('gemini-3.1-pro-high');
|
||||
});
|
||||
|
||||
it('strips non-parity Gemini usage metadata fields', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||
@@ -631,4 +652,43 @@ describe('ProxyService Protocol Parity Fixtures', () => {
|
||||
expect(output).toContain('"content":"final answer"');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('propagates OpenAI-compatible upstream stream errors instead of completing with [DONE]', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = (service as any).processStreamResponse(stream, 'gpt-4o-mini');
|
||||
const chunks: string[] = [];
|
||||
|
||||
const streamResult = await new Promise<{ error?: Error; completed: boolean }>((resolve) => {
|
||||
observable.subscribe({
|
||||
next: (chunk: string) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
error: (error: unknown) =>
|
||||
resolve({
|
||||
completed: false,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
}),
|
||||
complete: () => resolve({ completed: true }),
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'partial output' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('data', Buffer.from(`data: ${payload}\n`));
|
||||
stream.emit('error', new Error('socket hang up'));
|
||||
});
|
||||
|
||||
expect(streamResult.completed).not.toBe(true);
|
||||
expect(streamResult.error?.message).toContain('socket hang up');
|
||||
expect(chunks.join('')).toContain('"content":"partial output"');
|
||||
expect(chunks.join('')).not.toContain('data: [DONE]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker';
|
||||
|
||||
describe('RateLimitTracker parity replay', () => {
|
||||
it('uses Retry-After header before body/default', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const info = tracker.parseAndMarkFromError({
|
||||
accountId: 'acc-1',
|
||||
status: 429,
|
||||
retryAfter: '30',
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
details: [{ reason: 'RATE_LIMIT_EXCEEDED' }],
|
||||
retry_after: 1,
|
||||
},
|
||||
}),
|
||||
model: 'gemini-2.5-pro',
|
||||
backoffSteps: [60, 300, 1800, 7200],
|
||||
});
|
||||
|
||||
expect(info).not.toBeNull();
|
||||
expect(info?.reason).toBe(RateLimitReason.RateLimitExceeded);
|
||||
expect(info?.retryAfterSec).toBe(30);
|
||||
expect(tracker.isRateLimited('acc-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses model-level key for quota exhausted', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
tracker.parseAndMarkFromError({
|
||||
accountId: 'acc-2',
|
||||
status: 429,
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
details: [
|
||||
{
|
||||
reason: 'QUOTA_EXHAUSTED',
|
||||
metadata: { quotaResetDelay: '42s' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
model: 'gemini-2.5-flash',
|
||||
backoffSteps: [60, 300, 1800, 7200],
|
||||
});
|
||||
|
||||
expect(tracker.isRateLimited('acc-2', 'gemini-2.5-flash')).toBe(true);
|
||||
expect(tracker.isRateLimited('acc-2', 'gemini-2.5-pro')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses backoff steps when no header/body retry hint', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const steps = [60, 300, 1800, 7200];
|
||||
|
||||
const first = tracker.parseAndMarkFromError({
|
||||
accountId: 'acc-3',
|
||||
status: 429,
|
||||
body: JSON.stringify({
|
||||
error: { details: [{ reason: 'QUOTA_EXHAUSTED' }] },
|
||||
}),
|
||||
backoffSteps: steps,
|
||||
});
|
||||
const second = tracker.parseAndMarkFromError({
|
||||
accountId: 'acc-3',
|
||||
status: 429,
|
||||
body: JSON.stringify({
|
||||
error: { details: [{ reason: 'QUOTA_EXHAUSTED' }] },
|
||||
}),
|
||||
backoffSteps: steps,
|
||||
});
|
||||
|
||||
expect(first?.retryAfterSec).toBe(60);
|
||||
expect(second?.retryAfterSec).toBe(300);
|
||||
});
|
||||
|
||||
it('parses MODEL_CAPACITY_EXHAUSTED and RetryInfo.retryDelay from 503 payload', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const info = tracker.parseAndMarkFromError({
|
||||
accountId: 'acc-4',
|
||||
status: 503,
|
||||
model: 'gemini-3.1-pro-high',
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
code: 503,
|
||||
message: 'No capacity available for model gemini-3.1-pro-high on the server',
|
||||
status: 'UNAVAILABLE',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'MODEL_CAPACITY_EXHAUSTED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: { model: 'gemini-3.1-pro-high' },
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||
retryDelay: '30s',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
backoffSteps: [60, 300, 1800, 7200],
|
||||
});
|
||||
|
||||
expect(info).not.toBeNull();
|
||||
expect(info?.reason).toBe(RateLimitReason.ModelCapacityExhausted);
|
||||
expect(info?.retryAfterSec).toBe(30);
|
||||
expect(tracker.isRateLimited('acc-4')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,8 @@ describe('decodeSignature', () => {
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
|
||||
it('should decode UTF-8 Chinese signature', () => {
|
||||
const original = '思考签名-测试';
|
||||
it('should decode UTF-8 signature text', () => {
|
||||
const original = 'thinking-signature-test-utf8';
|
||||
const base64 = Buffer.from(original).toString('base64');
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper';
|
||||
import { PartProcessor, StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper';
|
||||
|
||||
describe('StreamingState', () => {
|
||||
let state: StreamingState;
|
||||
@@ -58,4 +58,48 @@ describe('StreamingState', () => {
|
||||
expect(state.getErrorCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream aggregation compatibility', () => {
|
||||
it('emits tool_use stop reason when functionCall appears in stream', () => {
|
||||
const processor = new PartProcessor(state);
|
||||
const functionChunks = processor.process({
|
||||
functionCall: {
|
||||
name: 'builtin_web_search',
|
||||
args: { query: 'gemini docs' },
|
||||
id: 'call_stream_1',
|
||||
},
|
||||
});
|
||||
const finishChunks = state.emitFinish('STOP', {
|
||||
promptTokenCount: 2,
|
||||
candidatesTokenCount: 3,
|
||||
} as any);
|
||||
|
||||
const output = [...functionChunks, ...finishChunks].join('');
|
||||
expect(output).toContain('"type":"tool_use"');
|
||||
expect(output).toContain('"stop_reason":"tool_use"');
|
||||
expect(output).toContain('"message_stop"');
|
||||
});
|
||||
|
||||
it('aggregates grounding metadata into final text block', () => {
|
||||
state.webSearchQuery = 'gemini api';
|
||||
state.groundingChunks = [
|
||||
{
|
||||
web: {
|
||||
title: 'Gemini API Docs',
|
||||
uri: 'https://example.com/gemini',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const chunks = state.emitFinish('STOP', {
|
||||
promptTokenCount: 1,
|
||||
candidatesTokenCount: 1,
|
||||
} as any);
|
||||
const output = chunks.join('');
|
||||
|
||||
expect(output).toContain('Searched for you');
|
||||
expect(output).toContain('Citations');
|
||||
expect(output).toContain('https://example.com/gemini');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_APP_CONFIG, ProxyConfig } from '../../types/config';
|
||||
import { setServerConfig } from '../../server/server-config';
|
||||
import { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
function createProxyConfig(overrides: Partial<ProxyConfig>): ProxyConfig {
|
||||
return {
|
||||
...DEFAULT_APP_CONFIG.proxy,
|
||||
...overrides,
|
||||
upstream_proxy: {
|
||||
...DEFAULT_APP_CONFIG.proxy.upstream_proxy,
|
||||
...(overrides.upstream_proxy ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function seedTokens(service: TokenManagerService): void {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
(service as any).tokens = new Map([
|
||||
[
|
||||
'acc-1',
|
||||
{
|
||||
account_id: 'acc-1',
|
||||
email: '[email protected]',
|
||||
access_token: 'token-1',
|
||||
refresh_token: 'refresh-1',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: nowSec + 3600,
|
||||
project_id: 'project-1',
|
||||
session_id: 'session-1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'acc-2',
|
||||
{
|
||||
account_id: 'acc-2',
|
||||
email: '[email protected]',
|
||||
access_token: 'token-2',
|
||||
refresh_token: 'refresh-2',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: nowSec + 3600,
|
||||
project_id: 'project-2',
|
||||
session_id: 'session-2',
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
describe('TokenManagerService parity scheduling replay', () => {
|
||||
let service: TokenManagerService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new TokenManagerService();
|
||||
seedTokens(service);
|
||||
});
|
||||
|
||||
it('rewrites gemini pro model to first available account candidate', () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
(service as any).tokens = new Map([
|
||||
[
|
||||
'acc-1',
|
||||
{
|
||||
account_id: 'acc-1',
|
||||
email: '[email protected]',
|
||||
access_token: 'token-1',
|
||||
refresh_token: 'refresh-1',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: nowSec + 3600,
|
||||
project_id: 'project-1',
|
||||
session_id: 'session-1',
|
||||
model_quotas: {
|
||||
'gemini-3.1-pro-low': 80,
|
||||
},
|
||||
model_limits: {},
|
||||
model_reset_times: {},
|
||||
model_forwarding_rules: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const resolved = service.resolveDynamicModelForAccount('acc-1', 'gemini-3-pro');
|
||||
expect(resolved).toBe('gemini-3.1-pro-low');
|
||||
});
|
||||
|
||||
it('keeps original model when dynamic rewrite is not applicable', () => {
|
||||
const resolved = service.resolveDynamicModelForAccount('acc-1', 'gemini-3-flash');
|
||||
expect(resolved).toBe('gemini-3-flash');
|
||||
});
|
||||
|
||||
it('passes oauth_client_key when refreshing token and persists refreshed key', async () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const tokenData = {
|
||||
account_id: 'acc-1',
|
||||
email: '[email protected]',
|
||||
access_token: 'token-1',
|
||||
refresh_token: 'refresh-1',
|
||||
oauth_client_key: 'custom-client',
|
||||
upstream_proxy_url: 'http://127.0.0.1:8080',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: nowSec - 1,
|
||||
project_id: 'project-1',
|
||||
session_id: 'session-1',
|
||||
model_quotas: {},
|
||||
model_limits: {},
|
||||
model_reset_times: {},
|
||||
model_forwarding_rules: {},
|
||||
};
|
||||
|
||||
(service as any).tokens = new Map([['acc-1', tokenData]]);
|
||||
|
||||
const refreshSpy = vi.spyOn(GoogleAPIService, 'refreshAccessToken').mockResolvedValue({
|
||||
access_token: 'token-new',
|
||||
expires_in: 7200,
|
||||
token_type: 'Bearer',
|
||||
oauth_client_key: 'custom-fallback',
|
||||
});
|
||||
const persistSpy = vi.spyOn(service as any, 'persistTokenState').mockResolvedValue(undefined);
|
||||
|
||||
const selected = await (service as any).finalizeSelectedToken('acc-1', tokenData, nowSec);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(
|
||||
'refresh-1',
|
||||
'http://127.0.0.1:8080',
|
||||
'custom-client',
|
||||
);
|
||||
expect(selected?.token.oauth_client_key).toBe('custom-fallback');
|
||||
expect((service as any).tokens.get('acc-1')?.oauth_client_key).toBe('custom-fallback');
|
||||
|
||||
refreshSpy.mockRestore();
|
||||
persistSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps oauth_client_key unset for legacy account refreshed by enterprise client', () => {
|
||||
const normalized = (service as any).normalizeRefreshedOauthClientKey(
|
||||
{
|
||||
oauth_client_key: undefined,
|
||||
project_id: undefined,
|
||||
},
|
||||
'antigravity_enterprise',
|
||||
);
|
||||
|
||||
expect(normalized).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prioritizes preferred account in parity mode', async () => {
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: true,
|
||||
parity_kill_switch: false,
|
||||
scheduling_mode: 'balance',
|
||||
preferred_account_id: 'acc-2',
|
||||
}),
|
||||
);
|
||||
|
||||
const token = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
expect(token?.id).toBe('acc-2');
|
||||
});
|
||||
|
||||
it('rotates sticky account when limited in balance mode', async () => {
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: true,
|
||||
parity_kill_switch: false,
|
||||
scheduling_mode: 'balance',
|
||||
preferred_account_id: '',
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await service.getNextToken({
|
||||
sessionKey: 'openai:user-1',
|
||||
model: 'gemini-2.5-flash',
|
||||
});
|
||||
expect(first?.id).toBe('acc-1');
|
||||
|
||||
await service.markFromUpstreamError({
|
||||
accountIdOrEmail: 'acc-1',
|
||||
status: 429,
|
||||
model: 'gemini-2.5-flash',
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
details: [{ reason: 'RATE_LIMIT_EXCEEDED' }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const second = await service.getNextToken({
|
||||
sessionKey: 'openai:user-1',
|
||||
model: 'gemini-2.5-flash',
|
||||
});
|
||||
expect(second?.id).toBe('acc-2');
|
||||
});
|
||||
|
||||
it('applies model-level lock for quota exhausted only on the same model', async () => {
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: true,
|
||||
parity_kill_switch: false,
|
||||
scheduling_mode: 'performance-first',
|
||||
}),
|
||||
);
|
||||
|
||||
await service.markFromUpstreamError({
|
||||
accountIdOrEmail: 'acc-1',
|
||||
status: 429,
|
||||
model: 'gemini-2.5-flash',
|
||||
body: JSON.stringify({
|
||||
error: {
|
||||
details: [{ reason: 'QUOTA_EXHAUSTED', metadata: { quotaResetDelay: '30s' } }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const sameModel = await service.getNextToken({ model: 'gemini-2.5-flash' });
|
||||
expect(sameModel?.id).toBe('acc-2');
|
||||
|
||||
const otherModel = await service.getNextToken({
|
||||
model: 'gemini-2.5-pro',
|
||||
excludeAccountIds: ['acc-2'],
|
||||
});
|
||||
expect(otherModel?.id).toBe('acc-1');
|
||||
});
|
||||
|
||||
it('falls back to excluded pool when retry exclusions would empty all candidates', async () => {
|
||||
setServerConfig(
|
||||
createProxyConfig({
|
||||
parity_enabled: false,
|
||||
parity_kill_switch: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
(service as any).tokens = new Map([
|
||||
[
|
||||
'acc-1',
|
||||
{
|
||||
account_id: 'acc-1',
|
||||
email: '[email protected]',
|
||||
access_token: 'token-1',
|
||||
refresh_token: 'refresh-1',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: nowSec + 3600,
|
||||
project_id: 'project-1',
|
||||
session_id: 'session-1',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const token = await service.getNextToken({ excludeAccountIds: ['acc-1'] });
|
||||
expect(token?.id).toBe('acc-1');
|
||||
});
|
||||
});
|
||||
+68
-14
@@ -14,18 +14,32 @@ export interface CloudTokenData {
|
||||
token_type: string;
|
||||
email?: string;
|
||||
project_id?: string;
|
||||
oauth_client_key?: string;
|
||||
session_id?: string;
|
||||
upstream_proxy_url?: string;
|
||||
is_gcp_tos?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudQuotaModelInfo {
|
||||
percentage: number;
|
||||
resetTime: string;
|
||||
display_name?: string;
|
||||
supports_images?: boolean;
|
||||
supports_thinking?: boolean;
|
||||
thinking_budget?: number;
|
||||
recommended?: boolean;
|
||||
max_tokens?: number;
|
||||
max_output_tokens?: number;
|
||||
supported_mime_types?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface CloudQuotaData {
|
||||
models: Record<
|
||||
string,
|
||||
{
|
||||
percentage: number;
|
||||
resetTime: string;
|
||||
}
|
||||
>;
|
||||
models: Record<string, CloudQuotaModelInfo>;
|
||||
model_forwarding_rules?: Record<string, string>;
|
||||
subscription_tier?: string;
|
||||
is_forbidden?: boolean;
|
||||
isForbidden?: boolean;
|
||||
ai_credits?: { credits: number; expiryDate: string };
|
||||
}
|
||||
|
||||
export interface CloudAccount {
|
||||
@@ -41,7 +55,9 @@ export interface CloudAccount {
|
||||
created_at: number;
|
||||
last_used: number; // Unix timestamp
|
||||
status?: 'active' | 'rate_limited' | 'expired';
|
||||
status_reason?: string;
|
||||
is_active?: boolean;
|
||||
proxy_url?: string;
|
||||
}
|
||||
|
||||
// Zod Schemas
|
||||
@@ -53,18 +69,32 @@ export const CloudTokenDataSchema = z.object({
|
||||
token_type: z.string(),
|
||||
email: z.string().optional(),
|
||||
project_id: z.string().optional(),
|
||||
oauth_client_key: z.string().optional(),
|
||||
session_id: z.string().optional(),
|
||||
upstream_proxy_url: z.string().optional(),
|
||||
is_gcp_tos: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CloudQuotaModelInfoSchema = z.object({
|
||||
percentage: z.number(),
|
||||
resetTime: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
supports_images: z.boolean().optional(),
|
||||
supports_thinking: z.boolean().optional(),
|
||||
thinking_budget: z.number().optional(),
|
||||
recommended: z.boolean().optional(),
|
||||
max_tokens: z.number().optional(),
|
||||
max_output_tokens: z.number().optional(),
|
||||
supported_mime_types: z.record(z.string(), z.boolean()).optional(),
|
||||
});
|
||||
|
||||
export const CloudQuotaDataSchema = z.object({
|
||||
models: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
percentage: z.number(),
|
||||
resetTime: z.string(),
|
||||
}),
|
||||
),
|
||||
models: z.record(z.string(), CloudQuotaModelInfoSchema),
|
||||
model_forwarding_rules: z.record(z.string(), z.string()).optional(),
|
||||
subscription_tier: z.string().optional(),
|
||||
is_forbidden: z.boolean().optional(),
|
||||
isForbidden: z.boolean().optional(),
|
||||
ai_credits: z.object({ credits: z.number(), expiryDate: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export const CloudAccountSchema = z.object({
|
||||
@@ -80,5 +110,29 @@ export const CloudAccountSchema = z.object({
|
||||
created_at: z.number(),
|
||||
last_used: z.number(),
|
||||
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
||||
status_reason: z.string().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
proxy_url: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CloudAccountExportSchema = z.object({
|
||||
version: z.literal('1.0'),
|
||||
exportedAt: z.number(),
|
||||
accounts: z.array(
|
||||
z.object({
|
||||
provider: z.enum(['google', 'anthropic']),
|
||||
email: z.string(),
|
||||
name: z.string().optional().nullable(),
|
||||
avatar_url: z.string().optional().nullable(),
|
||||
token: CloudTokenDataSchema,
|
||||
quota: CloudQuotaDataSchema.optional(),
|
||||
device_profile: z.any().optional(),
|
||||
device_history: z.any().optional(),
|
||||
proxy_url: z.string().optional().nullable(),
|
||||
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
||||
status_reason: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type CloudAccountExport = z.infer<typeof CloudAccountExportSchema>;
|
||||
+37
-9
@@ -6,14 +6,24 @@ export const UpstreamProxyConfigSchema = z.object({
|
||||
});
|
||||
|
||||
export const ProxyConfigSchema = z.object({
|
||||
enabled: z.boolean(), // 是否启用
|
||||
port: z.number(), // 监听端口
|
||||
api_key: z.string(), // API 密钥 (Also used for Admin Mode auth)
|
||||
auto_start: z.boolean(), // 是否自动启动
|
||||
enabled: z.boolean(),
|
||||
port: z.number(),
|
||||
api_key: z.string(),
|
||||
auto_start: z.boolean(),
|
||||
backend_canary_enabled: z.boolean().default(true),
|
||||
parity_enabled: z.boolean().default(false),
|
||||
parity_shadow_enabled: z.boolean().default(false),
|
||||
parity_kill_switch: z.boolean().default(false),
|
||||
parity_no_go_mismatch_rate: z.number().default(0.15),
|
||||
parity_no_go_error_rate: z.number().default(0.4),
|
||||
scheduling_mode: z.enum(['cache-first', 'balance', 'performance-first']).default('balance'),
|
||||
max_wait_seconds: z.number().default(60),
|
||||
preferred_account_id: z.string().default(''),
|
||||
circuit_breaker_enabled: z.boolean().default(true),
|
||||
circuit_breaker_backoff_steps: z.array(z.number()).default([60, 300, 1800, 7200]),
|
||||
custom_mapping: z.record(z.string(), z.string()).default({}),
|
||||
anthropic_mapping: z.record(z.string(), z.string()), // 映射表
|
||||
request_timeout: z.number().default(120), // 超时秒数
|
||||
anthropic_mapping: z.record(z.string(), z.string()), // Mapping table
|
||||
request_timeout: z.number().default(120), // Timeout in seconds
|
||||
upstream_proxy: UpstreamProxyConfigSchema,
|
||||
});
|
||||
|
||||
@@ -27,10 +37,15 @@ export const AppConfigSchema = z.object({
|
||||
auto_startup: z.boolean(),
|
||||
error_reporting_enabled: z.boolean(),
|
||||
privacy_consent_asked: z.boolean().optional().default(false), // Optional for backward compatibility
|
||||
default_export_path: z.string().nullable().optional(), // 导出路径
|
||||
default_export_path: z.string().nullable().optional(), // Export path
|
||||
model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences
|
||||
provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI
|
||||
grid_layout: z.enum(['auto', '2-col', '3-col', 'list']).default('auto'), // Account card grid layout
|
||||
grid_layout: z.enum(['auto', '2-col', '3-col', 'list', 'compact']).default('auto'), // Account card grid layout
|
||||
account_sort: z
|
||||
.enum(['recently-used', 'quota-overall', 'quota-claude', 'quota-pro3', 'quota-flash'])
|
||||
.default('recently-used'),
|
||||
quota_alert_enabled: z.boolean().default(false),
|
||||
quota_alert_threshold: z.number().default(20),
|
||||
proxy: ProxyConfigSchema,
|
||||
});
|
||||
|
||||
@@ -40,7 +55,7 @@ export type AppConfig = z.infer<typeof AppConfigSchema>;
|
||||
|
||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
language: 'zh-CN',
|
||||
theme: 'system',
|
||||
theme: 'dark',
|
||||
auto_refresh: false,
|
||||
refresh_interval: 15,
|
||||
auto_sync: false,
|
||||
@@ -52,12 +67,25 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
model_visibility: {}, // Model visibility preferences
|
||||
provider_groupings_enabled: false, // Enable provider groupings UI
|
||||
grid_layout: 'auto' as const, // Account card grid layout
|
||||
account_sort: 'recently-used' as const,
|
||||
quota_alert_enabled: false,
|
||||
quota_alert_threshold: 20,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
port: 8045,
|
||||
api_key: '', // Generated dynamically if default needed
|
||||
auto_start: false,
|
||||
backend_canary_enabled: true,
|
||||
parity_enabled: false,
|
||||
parity_shadow_enabled: false,
|
||||
parity_kill_switch: false,
|
||||
parity_no_go_mismatch_rate: 0.15,
|
||||
parity_no_go_error_rate: 0.4,
|
||||
scheduling_mode: 'balance',
|
||||
max_wait_seconds: 60,
|
||||
preferred_account_id: '',
|
||||
circuit_breaker_enabled: true,
|
||||
circuit_breaker_backoff_steps: [60, 300, 1800, 7200],
|
||||
custom_mapping: {},
|
||||
anthropic_mapping: {},
|
||||
request_timeout: 120,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export const RATE_LIMIT_HINTS = [
|
||||
'resource_exhausted',
|
||||
'too many requests',
|
||||
'429',
|
||||
'rate limit',
|
||||
'rate-limited',
|
||||
'risk control',
|
||||
'risk-controlled',
|
||||
'frozen',
|
||||
] as const;
|
||||
|
||||
export const OAUTH_HINTS = [
|
||||
'unauthorized_client',
|
||||
'invalid_client',
|
||||
'invalid_grant',
|
||||
'oauth client',
|
||||
'not authorized',
|
||||
'token expired',
|
||||
'reauth',
|
||||
'verify your account',
|
||||
'further action is required',
|
||||
'validation required',
|
||||
'validation_url',
|
||||
'appeal_url',
|
||||
] as const;
|
||||
|
||||
function includesAny(text: string, hints: readonly string[]): boolean {
|
||||
return hints.some((hint) => text.includes(hint));
|
||||
}
|
||||
|
||||
export function isRateLimitReason(reason: string): boolean {
|
||||
return includesAny(reason.toLowerCase(), RATE_LIMIT_HINTS);
|
||||
}
|
||||
|
||||
export function isOAuthReauthReason(reason: string): boolean {
|
||||
return includesAny(reason.toLowerCase(), OAUTH_HINTS);
|
||||
}
|
||||
|
||||
export function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function classifyAccountStatusFromError(
|
||||
error: unknown,
|
||||
): { status: 'rate_limited' | 'expired'; reason: string } | null {
|
||||
const reason = extractErrorMessage(error).trim();
|
||||
if (!reason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedReason = reason.toLowerCase();
|
||||
if (isRateLimitReason(normalizedReason)) {
|
||||
return { status: 'rate_limited', reason };
|
||||
}
|
||||
if (isOAuthReauthReason(normalizedReason)) {
|
||||
return { status: 'expired', reason };
|
||||
}
|
||||
|
||||
if (normalizedReason.includes('forbidden')) {
|
||||
return { status: 'rate_limited', reason };
|
||||
}
|
||||
if (normalizedReason.includes('unauthorized')) {
|
||||
return { status: 'expired', reason };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function shouldAutoSubmitGoogleAuthCode(params: {
|
||||
authCode: string;
|
||||
isAddDialogOpen: boolean;
|
||||
isPending: boolean;
|
||||
lastSubmittedAuthCode: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
params.authCode.trim() !== '' &&
|
||||
params.isAddDialogOpen &&
|
||||
!params.isPending &&
|
||||
params.authCode !== params.lastSubmittedAuthCode
|
||||
);
|
||||
}
|
||||
+29
-4
@@ -236,11 +236,36 @@ export function getAntigravityExecutablePath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
return possiblePaths[0];
|
||||
// No known path found; return empty string (caller must handle missing binary)
|
||||
return '';
|
||||
}
|
||||
case 'linux': {
|
||||
const possibleLinuxPaths = [
|
||||
'/usr/bin/antigravity',
|
||||
'/usr/local/bin/antigravity',
|
||||
'/usr/share/antigravity/antigravity',
|
||||
'/opt/Antigravity/antigravity',
|
||||
'/opt/antigravity/antigravity',
|
||||
path.join(os.homedir(), '.local', 'share', 'antigravity', 'antigravity'),
|
||||
];
|
||||
|
||||
for (const p of possibleLinuxPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try `which antigravity` via path lookup
|
||||
const fromPath = process.env.PATH?.split(':')
|
||||
.map((dir) => path.join(dir, 'antigravity'))
|
||||
.find((p) => fs.existsSync(p));
|
||||
if (fromPath) {
|
||||
return fromPath;
|
||||
}
|
||||
|
||||
// No known path found; return empty string (caller must handle missing binary)
|
||||
return '';
|
||||
}
|
||||
case 'linux':
|
||||
return '/usr/share/antigravity/antigravity';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
+205
-34
@@ -1,4 +1,15 @@
|
||||
export class ProtobufUtils {
|
||||
private static concatBytes(...parts: Uint8Array[]): Uint8Array {
|
||||
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
|
||||
const merged = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
merged.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
static encodeVarint(value: number): Uint8Array {
|
||||
// Note: Javascript bitwise operators treat operands as 32-bit integers.
|
||||
// However, for typical token lengths and small field IDs, this is fine.
|
||||
@@ -111,6 +122,13 @@ export class ProtobufUtils {
|
||||
return this.encodeLenDelimField(fieldNum, bytes);
|
||||
}
|
||||
|
||||
static encodeVarintField(fieldNum: number, value: number): Uint8Array {
|
||||
const tag = (fieldNum << 3) | 0;
|
||||
const tagBytes = this.encodeVarint(tag);
|
||||
const valueBytes = this.encodeVarint(value);
|
||||
return this.concatBytes(tagBytes, valueBytes);
|
||||
}
|
||||
|
||||
// Create timestamp field (Field 4 -> Field 1 varint)
|
||||
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
||||
// Timestamp message format: Field 1 (seconds) as varint
|
||||
@@ -217,7 +235,12 @@ export class ProtobufUtils {
|
||||
return result;
|
||||
}
|
||||
|
||||
static createOAuthInfo(accessToken: string, refreshToken: string, expiry: number): Uint8Array {
|
||||
static createOAuthInfo(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiry: number,
|
||||
isGcpTos = true,
|
||||
): Uint8Array {
|
||||
const field1 = this.encodeStringField(1, accessToken);
|
||||
const field2 = this.encodeStringField(2, 'Bearer');
|
||||
const field3 = this.encodeStringField(3, refreshToken);
|
||||
@@ -230,12 +253,16 @@ export class ProtobufUtils {
|
||||
timestampMsg.set(secondsBytes, tagBytes.length);
|
||||
|
||||
const field4 = this.encodeLenDelimField(4, timestampMsg);
|
||||
const field6 = isGcpTos ? this.encodeVarintField(6, 1) : new Uint8Array();
|
||||
|
||||
const combined = new Uint8Array(field1.length + field2.length + field3.length + field4.length);
|
||||
const combined = new Uint8Array(
|
||||
field1.length + field2.length + field3.length + field4.length + field6.length,
|
||||
);
|
||||
combined.set(field1, 0);
|
||||
combined.set(field2, field1.length);
|
||||
combined.set(field3, field1.length + field2.length);
|
||||
combined.set(field4, field1.length + field2.length + field3.length);
|
||||
combined.set(field6, field1.length + field2.length + field3.length + field4.length);
|
||||
return combined;
|
||||
}
|
||||
|
||||
@@ -243,20 +270,10 @@ export class ProtobufUtils {
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiry: number,
|
||||
isGcpTos = true,
|
||||
): string {
|
||||
const oauthInfo = this.createOAuthInfo(accessToken, refreshToken, expiry);
|
||||
const oauthInfoB64 = Buffer.from(oauthInfo).toString('base64');
|
||||
|
||||
const inner2 = this.encodeStringField(1, oauthInfoB64);
|
||||
const inner1 = this.encodeStringField(1, 'oauthTokenInfoSentinelKey');
|
||||
const innerField2 = this.encodeLenDelimField(2, inner2);
|
||||
|
||||
const inner = new Uint8Array(inner1.length + innerField2.length);
|
||||
inner.set(inner1, 0);
|
||||
inner.set(innerField2, inner1.length);
|
||||
|
||||
const outer = this.encodeLenDelimField(1, inner);
|
||||
return Buffer.from(outer).toString('base64');
|
||||
const oauthInfo = this.createOAuthInfo(accessToken, refreshToken, expiry, isGcpTos);
|
||||
return this.createUnifiedStateEntry('oauthTokenInfoSentinelKey', oauthInfo);
|
||||
}
|
||||
|
||||
static extractOAuthTokenInfoFromOAuthInfo(
|
||||
@@ -277,29 +294,183 @@ export class ProtobufUtils {
|
||||
static extractOAuthTokenInfoFromUnifiedState(
|
||||
data: Uint8Array,
|
||||
): { accessToken: string; refreshToken: string } | null {
|
||||
const inner = this.getField(data, 1);
|
||||
if (!inner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inner2 = this.getField(inner, 2);
|
||||
if (!inner2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oauthInfoB64Bytes = this.getField(inner2, 1);
|
||||
if (!oauthInfoB64Bytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oauthInfoB64 = this.readString(oauthInfoB64Bytes);
|
||||
let oauthInfoBytes: Uint8Array;
|
||||
let decoded: { sentinelKey: string; payload: Uint8Array };
|
||||
try {
|
||||
oauthInfoBytes = new Uint8Array(Buffer.from(oauthInfoB64, 'base64'));
|
||||
decoded = this.decodeTopicRowPayload(data);
|
||||
} catch {
|
||||
try {
|
||||
decoded = this.decodeLegacyUnifiedStateEntry(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (decoded.sentinelKey !== 'oauthTokenInfoSentinelKey') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directParsed = this.extractOAuthTokenInfoFromOAuthInfo(decoded.payload);
|
||||
if (directParsed) {
|
||||
return directParsed;
|
||||
}
|
||||
const nestedOauthInfoB64Bytes = this.getField(decoded.payload, 1);
|
||||
if (!nestedOauthInfoB64Bytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const nestedOauthInfoBytes = new Uint8Array(
|
||||
Buffer.from(this.readString(nestedOauthInfoB64Bytes), 'base64'),
|
||||
);
|
||||
return this.extractOAuthTokenInfoFromOAuthInfo(nestedOauthInfoBytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return this.extractOAuthTokenInfoFromOAuthInfo(oauthInfoBytes);
|
||||
private static decodeLegacyBase64PayloadIfNeeded(payload: Uint8Array): Uint8Array {
|
||||
if (payload.length === 0 || payload.length % 4 !== 0) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
let looksLikeBase64 = true;
|
||||
for (const byte of payload) {
|
||||
const isBase64Byte =
|
||||
(byte >= 65 && byte <= 90) ||
|
||||
(byte >= 97 && byte <= 122) ||
|
||||
(byte >= 48 && byte <= 57) ||
|
||||
byte === 43 ||
|
||||
byte === 47 ||
|
||||
byte === 61;
|
||||
if (!isBase64Byte) {
|
||||
looksLikeBase64 = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!looksLikeBase64) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
const encoded = Buffer.from(payload).toString('utf8');
|
||||
const decoded = new Uint8Array(Buffer.from(encoded, 'base64'));
|
||||
if (decoded.length === 0) {
|
||||
return payload;
|
||||
}
|
||||
return decoded;
|
||||
} catch {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
private static decodeTopicRowPayload(topicBlob: Uint8Array): {
|
||||
sentinelKey: string;
|
||||
payload: Uint8Array;
|
||||
} {
|
||||
const dataEntry = this.getField(topicBlob, 1);
|
||||
if (!dataEntry) {
|
||||
throw new Error('Topic data entry not found');
|
||||
}
|
||||
|
||||
const sentinelKeyBytes = this.getField(dataEntry, 1);
|
||||
if (!sentinelKeyBytes) {
|
||||
throw new Error('Topic data entry key not found');
|
||||
}
|
||||
|
||||
const rowBlob = this.getField(dataEntry, 2);
|
||||
if (!rowBlob) {
|
||||
throw new Error('Topic row not found');
|
||||
}
|
||||
|
||||
const encodedPayloadBytes = this.getField(rowBlob, 1);
|
||||
if (!encodedPayloadBytes) {
|
||||
throw new Error('Topic row value not found');
|
||||
}
|
||||
|
||||
const sentinelKey = this.readString(sentinelKeyBytes);
|
||||
const encodedPayload = this.readString(encodedPayloadBytes);
|
||||
const payload = new Uint8Array(Buffer.from(encodedPayload, 'base64'));
|
||||
|
||||
return { sentinelKey, payload };
|
||||
}
|
||||
|
||||
private static decodeLegacyUnifiedStateEntry(outerBlob: Uint8Array): {
|
||||
sentinelKey: string;
|
||||
payload: Uint8Array;
|
||||
} {
|
||||
const innerBlob = this.getField(outerBlob, 1);
|
||||
if (!innerBlob) {
|
||||
throw new Error('Outer Field 1 not found');
|
||||
}
|
||||
|
||||
const sentinelKeyBytes = this.getField(innerBlob, 1);
|
||||
if (!sentinelKeyBytes) {
|
||||
throw new Error('Inner Field 1 not found');
|
||||
}
|
||||
|
||||
const payload = this.getField(innerBlob, 2);
|
||||
if (!payload) {
|
||||
throw new Error('Inner Field 2 not found');
|
||||
}
|
||||
|
||||
return {
|
||||
sentinelKey: this.readString(sentinelKeyBytes),
|
||||
payload: this.decodeLegacyBase64PayloadIfNeeded(payload),
|
||||
};
|
||||
}
|
||||
|
||||
static createUnifiedStateEntry(sentinelKey: string, payload: Uint8Array): string {
|
||||
const row = this.encodeStringField(1, Buffer.from(payload).toString('base64'));
|
||||
const dataEntry = this.concatBytes(
|
||||
this.encodeStringField(1, sentinelKey),
|
||||
this.encodeLenDelimField(2, row),
|
||||
);
|
||||
const topic = this.encodeLenDelimField(1, dataEntry);
|
||||
return Buffer.from(topic).toString('base64');
|
||||
}
|
||||
|
||||
static decodeUnifiedStateEntry(outerB64: string): { sentinelKey: string; payload: Uint8Array } {
|
||||
const outerBlob = new Uint8Array(Buffer.from(outerB64, 'base64'));
|
||||
|
||||
try {
|
||||
return this.decodeTopicRowPayload(outerBlob);
|
||||
} catch {
|
||||
return this.decodeLegacyUnifiedStateEntry(outerBlob);
|
||||
}
|
||||
}
|
||||
|
||||
static findVarintField(data: Uint8Array, targetField: number): number | null {
|
||||
let offset = 0;
|
||||
while (offset < data.length) {
|
||||
const { value: tag, nextOffset } = this.readVarint(data, offset);
|
||||
const wireType = Number(tag & 7n);
|
||||
const currentField = Number(tag >> 3n);
|
||||
|
||||
if (currentField === targetField && wireType === 0) {
|
||||
const { value } = this.readVarint(data, nextOffset);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
offset = this.skipField(data, nextOffset, wireType);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static createStringValuePayload(value: string): Uint8Array {
|
||||
return this.encodeStringField(3, value);
|
||||
}
|
||||
|
||||
static createMinimalUserStatusPayload(email: string): Uint8Array {
|
||||
return this.concatBytes(this.encodeStringField(3, email), this.encodeStringField(7, email));
|
||||
}
|
||||
|
||||
static extractOAuthTokenInfoFromUnifiedStateEntry(
|
||||
outerB64: string,
|
||||
): { accessToken: string; refreshToken: string } | null {
|
||||
return this.extractOAuthTokenInfoFromUnifiedState(
|
||||
new Uint8Array(Buffer.from(outerB64, 'base64')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
* and calculating aggregated statistics.
|
||||
*/
|
||||
|
||||
import { flatMap, groupBy, keys, map, min, sortBy, sumBy } from 'lodash-es';
|
||||
import { roundQuotaPercentage } from '@/utils/quota-display';
|
||||
|
||||
export interface ProviderInfo {
|
||||
name: string;
|
||||
company: string;
|
||||
@@ -29,16 +32,25 @@ export const PROVIDER_REGISTRY: Record<string, ProviderInfo> = {
|
||||
|
||||
export type ProviderKey = keyof typeof PROVIDER_REGISTRY;
|
||||
|
||||
const HEALTH_STATUS_THRESHOLDS = {
|
||||
critical: 10,
|
||||
limited: 25,
|
||||
degraded: 50,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Detect the provider key for a given model name based on prefix matching.
|
||||
*/
|
||||
export function detectProvider(modelName: string): ProviderKey {
|
||||
for (const prefix of Object.keys(PROVIDER_REGISTRY)) {
|
||||
if (prefix !== 'others' && modelName.startsWith(prefix)) {
|
||||
return prefix;
|
||||
}
|
||||
const matchedPrefix = keys(PROVIDER_REGISTRY).find(
|
||||
(prefix) => prefix !== 'others' && modelName.startsWith(prefix),
|
||||
);
|
||||
|
||||
if (!matchedPrefix) {
|
||||
return 'others';
|
||||
}
|
||||
return 'others';
|
||||
|
||||
return matchedPrefix as ProviderKey;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +76,58 @@ export interface ProviderStats {
|
||||
earliestReset: string | null;
|
||||
}
|
||||
|
||||
function toRoundedAverage(models: ModelQuota[]): number {
|
||||
if (models.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const averagePercentage = sumBy(models, (model) => model.percentage) / models.length;
|
||||
|
||||
return roundQuotaPercentage(averagePercentage);
|
||||
}
|
||||
|
||||
function parseResetTimestamp(resetTime: string): number | null {
|
||||
if (!resetTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date(resetTime).getTime();
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function resolveAccountHealthStatus(overallPercentage: number): AccountStats['healthStatus'] {
|
||||
if (overallPercentage < HEALTH_STATUS_THRESHOLDS.critical) {
|
||||
return 'critical';
|
||||
}
|
||||
if (overallPercentage < HEALTH_STATUS_THRESHOLDS.limited) {
|
||||
return 'limited';
|
||||
}
|
||||
if (overallPercentage < HEALTH_STATUS_THRESHOLDS.degraded) {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
function buildProviderStats(
|
||||
providerKey: ProviderKey,
|
||||
models: ModelQuota[],
|
||||
visibleModels: ModelQuota[],
|
||||
earliestReset: string | null,
|
||||
): ProviderStats {
|
||||
return {
|
||||
providerKey,
|
||||
providerInfo: PROVIDER_REGISTRY[providerKey],
|
||||
models,
|
||||
visibleModels,
|
||||
avgPercentage: toRoundedAverage(visibleModels),
|
||||
earliestReset,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate aggregated stats for a group of models belonging to one provider.
|
||||
*/
|
||||
@@ -75,36 +139,18 @@ export function calculateProviderStats(
|
||||
const visibleModels = models.filter((m) => visibilitySettings[m.id] !== false);
|
||||
|
||||
if (visibleModels.length === 0) {
|
||||
return {
|
||||
providerKey,
|
||||
providerInfo: PROVIDER_REGISTRY[providerKey],
|
||||
models,
|
||||
visibleModels: [],
|
||||
avgPercentage: 0,
|
||||
earliestReset: null,
|
||||
};
|
||||
return buildProviderStats(providerKey, models, [], null);
|
||||
}
|
||||
|
||||
const avgPercentage =
|
||||
visibleModels.reduce((sum, m) => sum + m.percentage, 0) / visibleModels.length;
|
||||
|
||||
const resetTimes = visibleModels
|
||||
.map((m) => m.resetTime)
|
||||
.filter(Boolean)
|
||||
.map((time) => new Date(time).getTime())
|
||||
.filter((t) => !Number.isNaN(t));
|
||||
const resetTimes = map(visibleModels, (model) => parseResetTimestamp(model.resetTime)).filter(
|
||||
(timestamp): timestamp is number => timestamp !== null,
|
||||
);
|
||||
|
||||
const earliestTimestamp = min(resetTimes);
|
||||
const earliestReset =
|
||||
resetTimes.length > 0 ? new Date(Math.min(...resetTimes)).toISOString() : null;
|
||||
earliestTimestamp !== undefined ? new Date(earliestTimestamp).toISOString() : null;
|
||||
|
||||
return {
|
||||
providerKey,
|
||||
providerInfo: PROVIDER_REGISTRY[providerKey],
|
||||
models,
|
||||
visibleModels,
|
||||
avgPercentage: Math.round(avgPercentage * 10) / 10,
|
||||
earliestReset,
|
||||
};
|
||||
return buildProviderStats(providerKey, models, visibleModels, earliestReset);
|
||||
}
|
||||
|
||||
export interface AccountStats {
|
||||
@@ -122,53 +168,39 @@ export function groupModelsByProvider(
|
||||
models: Record<string, { percentage: number; resetTime: string }>,
|
||||
visibilitySettings: Record<string, boolean>,
|
||||
): AccountStats {
|
||||
const grouped: Record<ProviderKey, ModelQuota[]> = {};
|
||||
|
||||
for (const [modelName, info] of Object.entries(models)) {
|
||||
const key = detectProvider(modelName);
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push({
|
||||
const modelQuotas = map(models, (info, modelName) => ({
|
||||
providerKey: detectProvider(modelName),
|
||||
quota: {
|
||||
id: modelName,
|
||||
percentage: info.percentage,
|
||||
resetTime: info.resetTime,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const providers: ProviderStats[] = [];
|
||||
for (const [key, groupModels] of Object.entries(grouped)) {
|
||||
providers.push(calculateProviderStats(key, groupModels, visibilitySettings));
|
||||
}
|
||||
const providerModelGroups = groupBy(modelQuotas, (modelQuota) => modelQuota.providerKey);
|
||||
const providerStatsList = map(providerModelGroups, (groupedQuotas, key) => {
|
||||
const providerKey = key as ProviderKey;
|
||||
const providerModels = map(groupedQuotas, (groupedQuota) => groupedQuota.quota);
|
||||
|
||||
return calculateProviderStats(providerKey, providerModels, visibilitySettings);
|
||||
});
|
||||
|
||||
// Sort: known providers first (claude-, gemini-), then others
|
||||
const providerOrder = Object.keys(PROVIDER_REGISTRY);
|
||||
providers.sort(
|
||||
(a, b) => providerOrder.indexOf(a.providerKey) - providerOrder.indexOf(b.providerKey),
|
||||
const providerDisplayOrder = keys(PROVIDER_REGISTRY);
|
||||
const sortedProviders = sortBy(providerStatsList, (providerStats) =>
|
||||
providerDisplayOrder.indexOf(providerStats.providerKey),
|
||||
);
|
||||
|
||||
const allVisibleModels = providers.flatMap((p) => p.visibleModels);
|
||||
const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0);
|
||||
|
||||
const overallPercentage =
|
||||
allVisibleModels.length > 0
|
||||
? allVisibleModels.reduce((sum, m) => sum + m.percentage, 0) / allVisibleModels.length
|
||||
: 0;
|
||||
|
||||
let healthStatus: AccountStats['healthStatus'] = 'healthy';
|
||||
if (overallPercentage < 10) {
|
||||
healthStatus = 'critical';
|
||||
} else if (overallPercentage < 25) {
|
||||
healthStatus = 'limited';
|
||||
} else if (overallPercentage < 50) {
|
||||
healthStatus = 'degraded';
|
||||
}
|
||||
const allVisibleModels = flatMap(sortedProviders, (providerStats) => providerStats.visibleModels);
|
||||
const totalModels = sumBy(sortedProviders, (providerStats) => providerStats.models.length);
|
||||
const overallPercentage = toRoundedAverage(allVisibleModels);
|
||||
const healthStatus = resolveAccountHealthStatus(overallPercentage);
|
||||
|
||||
return {
|
||||
providers,
|
||||
providers: sortedProviders,
|
||||
totalModels,
|
||||
visibleModels: allVisibleModels.length,
|
||||
overallPercentage: Math.round(overallPercentage * 10) / 10,
|
||||
overallPercentage,
|
||||
healthStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { differenceInHours, differenceInMinutes, isBefore } from 'date-fns';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
const HIGH_QUOTA_PERCENTAGE = 80;
|
||||
const MEDIUM_QUOTA_PERCENTAGE = 20;
|
||||
|
||||
export type QuotaStatus = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface ResetTimeLabelOptions {
|
||||
prefix: string;
|
||||
unknown: string;
|
||||
}
|
||||
|
||||
export function getQuotaStatus(percentage: number): QuotaStatus {
|
||||
if (percentage > HIGH_QUOTA_PERCENTAGE) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if (percentage > MEDIUM_QUOTA_PERCENTAGE) {
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export function clampQuotaPercentage(percentage: number): number {
|
||||
return Math.max(0, Math.min(100, percentage));
|
||||
}
|
||||
|
||||
export function roundQuotaPercentage(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
export function formatTimeRemaining(dateStr: string): string | null {
|
||||
const targetDate = new Date(dateStr);
|
||||
if (Number.isNaN(targetDate.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (isBefore(targetDate, now)) {
|
||||
return '0h 0m';
|
||||
}
|
||||
|
||||
const diffHrs = Math.max(0, differenceInHours(targetDate, now));
|
||||
const diffMins = Math.max(0, differenceInMinutes(targetDate, now) - diffHrs * 60);
|
||||
if (diffHrs >= 24) {
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
const remainingHrs = diffHrs % 24;
|
||||
return `${diffDays}d ${remainingHrs}h`;
|
||||
}
|
||||
|
||||
return `${diffHrs}h ${diffMins}m`;
|
||||
}
|
||||
|
||||
export function formatResetTimeLabel(
|
||||
resetTime: string | undefined,
|
||||
labels: ResetTimeLabelOptions,
|
||||
): string {
|
||||
if (!resetTime) {
|
||||
return labels.unknown;
|
||||
}
|
||||
|
||||
const remaining = formatTimeRemaining(resetTime);
|
||||
if (!remaining) {
|
||||
return labels.unknown;
|
||||
}
|
||||
|
||||
return `${labels.prefix}: ${remaining}`;
|
||||
}
|
||||
|
||||
export function formatResetTimeTitle(
|
||||
resetTime: string | undefined,
|
||||
resetTimeLabel: string,
|
||||
): string | undefined {
|
||||
if (!resetTime) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resetDate = new Date(resetTime);
|
||||
if (Number.isNaN(resetDate.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${resetTimeLabel}: ${resetDate.toLocaleString()}`;
|
||||
}
|
||||
|
||||
export function getAccountSortValue(account: CloudAccount, sortKey: string): number {
|
||||
if (!account.quota?.models) return 0;
|
||||
const models = Object.values(account.quota.models);
|
||||
if (models.length === 0) return 0;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'quota-overall':
|
||||
return models.reduce((sum, m) => sum + m.percentage, 0) / models.length;
|
||||
case 'quota-claude': {
|
||||
const claude = models.filter((m) => m.percentage > 0);
|
||||
return claude.length > 0
|
||||
? claude.reduce((sum, m) => sum + m.percentage, 0) / claude.length
|
||||
: 0;
|
||||
}
|
||||
case 'quota-pro3': {
|
||||
const pro3 = models.filter((m) => m.percentage > 0 && /pro/i.test(m.display_name || ''));
|
||||
return pro3.length > 0 ? pro3.reduce((sum, m) => sum + m.percentage, 0) / pro3.length : 0;
|
||||
}
|
||||
case 'quota-flash': {
|
||||
const flash = models.filter((m) => m.percentage > 0 && /flash/i.test(m.display_name || ''));
|
||||
return flash.length > 0 ? flash.reduce((sum, m) => sum + m.percentage, 0) / flash.length : 0;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+24
-9
@@ -92,22 +92,29 @@ async function readSafeStorageKey(keyPath: string): Promise<Buffer | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
let encryptedKey: Buffer;
|
||||
try {
|
||||
encryptedKey = await fs.readFile(keyPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error('Security: Failed to read safeStorage key file', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedKey = await fs.readFile(keyPath);
|
||||
const hexKey = safeStorage.decryptString(encryptedKey);
|
||||
|
||||
if (!/^[a-f0-9]+$/i.test(hexKey) || hexKey.length !== 64) {
|
||||
logger.warn('Security: safeStorage key has invalid format');
|
||||
return null;
|
||||
}
|
||||
|
||||
return Buffer.from(hexKey, 'hex');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.warn('Security: Failed to read safeStorage key file', error);
|
||||
return null;
|
||||
logger.error('Security: Decryption of safeStorage key file failed. Keyring might be locked.', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,14 +176,15 @@ async function readFileFallbackKey(keyPath: string): Promise<Buffer | null> {
|
||||
return Buffer.from(content, 'hex');
|
||||
}
|
||||
|
||||
// If it's not a valid hex key but file exists, it might be a safeStorage encrypted key
|
||||
return null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.warn('Security: Error reading fallback key file', error);
|
||||
return null;
|
||||
logger.error('Security: Error reading fallback key file', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +301,13 @@ async function generatePrimaryMasterKey(): Promise<MasterKeyState> {
|
||||
}
|
||||
return { key: result.key, source: 'safeStorage' };
|
||||
} catch (error) {
|
||||
// If we failed to decrypt but the file exists, we should NOT proceed to other fallbacks
|
||||
// as they might overwrite the existing file and cause permanent data loss.
|
||||
const fileExists = await fs.access(keyPath).then(() => true).catch(() => false);
|
||||
if (fileExists) {
|
||||
logger.error('Security: safeStorage key file exists but decryption failed. Keyring might be locked. Stopping to prevent data loss.', error);
|
||||
throw error;
|
||||
}
|
||||
logger.warn('Security: safeStorage failed, trying keytar', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export function isValidProxyUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return ['http:', 'https:', 'socks:', 'socks4:', 'socks5:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(str: string): string {
|
||||
return str.replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[c] || c,
|
||||
);
|
||||
}
|
||||
+12
-13
@@ -9,19 +9,18 @@ export default defineConfig(({ mode }) => {
|
||||
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
||||
|
||||
return {
|
||||
plugins:
|
||||
shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
plugins: shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
define: {
|
||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||
},
|
||||
|
||||
+12
-13
@@ -8,19 +8,18 @@ export default defineConfig(({ mode }) => {
|
||||
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
||||
|
||||
return {
|
||||
plugins:
|
||||
shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
plugins: shouldEnableSentry
|
||||
? [
|
||||
sentryVitePlugin({
|
||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||
authToken: sentryAuthToken,
|
||||
release: {
|
||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(process.cwd(), './src'),
|
||||
|
||||
@@ -32,7 +32,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
]
|
||||
: []),
|
||||
tanstackRouter({
|
||||
target: 'react',
|
||||
|
||||
Reference in new issue
Block a user