Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e524a340cb | ||
|
|
6577f1c51f | ||
|
|
3e1e10b2a5 | ||
|
|
7093c982ba | ||
|
|
440a934280 | ||
|
|
87887dc4b9 | ||
|
|
00ff50055f | ||
|
|
d0c282dfff | ||
|
|
bc35b96b5d | ||
|
|
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
@@ -69,7 +69,7 @@ The project uses `orpc` for type-safe communication.
|
|||||||
|
|
||||||
### Database Access
|
### Database Access
|
||||||
|
|
||||||
Data is stored in a local SQLite file (`test.db` in dev, user data in prod).
|
Data is stored in a local SQLite file.
|
||||||
|
|
||||||
- Use `Better-SQLite3` for direct access.
|
- Use `Better-SQLite3` for direct access.
|
||||||
- Logic should be encapsulated in `src/services` or `src/ipc`.
|
- Logic should be encapsulated in `src/services` or `src/ipc`.
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# lodash-es-runtime-guards
|
||||||
|
|
||||||
|
Replace runtime `typeof` checks and ad-hoc empty-string checks with `lodash-es` predicates.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- You see runtime checks like `typeof value === 'string'`.
|
||||||
|
- You see object guards like `value && typeof value === 'object'`.
|
||||||
|
- You see emptiness checks like `value.trim() !== ''` or `value.trim() === ''`.
|
||||||
|
- You want consistent guard style across app, server, IPC, and tests.
|
||||||
|
|
||||||
|
## Do Not Replace
|
||||||
|
|
||||||
|
- Type-level `typeof` (TypeScript only), for example:
|
||||||
|
- `ReturnType<typeof fn>`
|
||||||
|
- `keyof typeof CONST_MAP`
|
||||||
|
- `z.infer<typeof Schema>`
|
||||||
|
|
||||||
|
These are compile-time types and must remain as-is.
|
||||||
|
|
||||||
|
## Guard Mapping
|
||||||
|
|
||||||
|
- `typeof x === 'string'` -> `isString(x)`
|
||||||
|
- `typeof x !== 'string'` -> `!isString(x)`
|
||||||
|
- `typeof x === 'number'` -> `isNumber(x)`
|
||||||
|
- `typeof x === 'boolean'` -> `isBoolean(x)`
|
||||||
|
- `typeof x === 'function'` -> `isFunction(x)`
|
||||||
|
- `typeof x === 'undefined'` -> `isUndefined(x)`
|
||||||
|
- `x && typeof x === 'object'` -> `isObjectLike(x)`
|
||||||
|
- `typeof x === 'object' && x !== null` -> `isObjectLike(x)`
|
||||||
|
- `typeof x === 'object' && !Array.isArray(x)` -> `isObjectLike(x) && !isArray(x)`
|
||||||
|
|
||||||
|
## String Emptiness Pattern
|
||||||
|
|
||||||
|
- Prefer:
|
||||||
|
- `isEmpty(value.trim())`
|
||||||
|
- Instead of:
|
||||||
|
- `value.trim() === ''`
|
||||||
|
- `value.trim() !== ''`
|
||||||
|
- `value.trim().length === 0`
|
||||||
|
|
||||||
|
## Import Style
|
||||||
|
|
||||||
|
- Use named imports from `lodash-es`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { isString, isNumber, isObjectLike, isEmpty } from 'lodash-es';
|
||||||
|
```
|
||||||
|
|
||||||
|
- Do not use full-package imports.
|
||||||
|
- Reuse existing imports in file; avoid duplicate imports.
|
||||||
|
|
||||||
|
## Refactor Checklist
|
||||||
|
|
||||||
|
1. Replace runtime checks first, preserving behavior.
|
||||||
|
2. Keep array/object branching semantics unchanged.
|
||||||
|
3. Re-scan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "typeof\s+[^\n]+(?:===|!==)\s*'" src
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Confirm only type-level `typeof` remains:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "\btypeof\b" src
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run verification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run type-check
|
||||||
|
```
|
||||||
|
|
||||||
@@ -123,3 +123,6 @@ build/
|
|||||||
cli/aliases.json
|
cli/aliases.json
|
||||||
*.backup
|
*.backup
|
||||||
accounts_backup.json
|
accounts_backup.json
|
||||||
|
|
||||||
|
# Bun lock file
|
||||||
|
bun.lock
|
||||||
Vendored
+24
@@ -12,6 +12,30 @@
|
|||||||
"outFiles": ["${workspaceFolder}/.vite/build/**/*.js"],
|
"outFiles": ["${workspaceFolder}/.vite/build/**/*.js"],
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"autoAttachChildProcesses": true
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -345,11 +345,6 @@ Documentation/usage lookup:
|
|||||||
|
|
||||||
- Use `context7` for latest dependency usage.
|
- Use `context7` for latest dependency usage.
|
||||||
|
|
||||||
VSCode MCP (if available):
|
|
||||||
|
|
||||||
- `mcp__vscode-mcp__get_references` for refactor impact analysis
|
|
||||||
- `mcp__vscode-mcp__rename_symbol` for safe renaming
|
|
||||||
- `mcp__vscode-mcp__get_symbol_lsp_info` for types/signatures/definitions
|
|
||||||
|
|
||||||
## 🚨 Local Quality Checks (Optional Flow)
|
## 🚨 Local Quality Checks (Optional Flow)
|
||||||
|
|
||||||
@@ -371,128 +366,3 @@ Task(subagent_type: "run-related-tests", description: "Run tests", prompt: "[sam
|
|||||||
Flow: initial check -> fix key issues -> re-check -> iterate until key issues are resolved.
|
Flow: initial check -> fix key issues -> re-check -> iterate until key issues are resolved.
|
||||||
|
|
||||||
Note: these tools are read-only analyzers; you still need to apply fixes manually. Pass precise file paths, not broad directories.
|
Note: these tools are read-only analyzers; you still need to apply fixes manually. Pass precise file paths, not broad directories.
|
||||||
|
|
||||||
<skills_system priority="1">
|
|
||||||
|
|
||||||
## Available Skills
|
|
||||||
|
|
||||||
<!-- SKILLS_TABLE_START -->
|
|
||||||
<usage>
|
|
||||||
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
|
|
||||||
|
|
||||||
How to use skills:
|
|
||||||
|
|
||||||
- Invoke: Bash("openskills read <skill-name>")
|
|
||||||
- The skill content will load with detailed instructions on how to complete the task
|
|
||||||
- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/)
|
|
||||||
|
|
||||||
Usage notes:
|
|
||||||
|
|
||||||
- Only use skills listed in <available_skills> below
|
|
||||||
- Do not invoke a skill that is already loaded in your context
|
|
||||||
- Each skill invocation is stateless
|
|
||||||
</usage>
|
|
||||||
|
|
||||||
<available_skills>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>algorithmic-art</name>
|
|
||||||
<description>Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>brand-guidelines</name>
|
|
||||||
<description>Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>canvas-design</name>
|
|
||||||
<description>Create beautiful visual art in .png and .pdf documents using design philosophy. You should use this skill when the user asks to create a poster, piece of art, design, or other static piece. Create original visual designs, never copying existing artists' work to avoid copyright violations.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>doc-coauthoring</name>
|
|
||||||
<description>Guide users through a structured workflow for co-authoring documentation. Use when user wants to write documentation, proposals, technical specs, decision docs, or similar structured content. This workflow helps users efficiently transfer context, refine content through iteration, and verify the doc works for readers. Trigger when user mentions writing docs, creating proposals, drafting specs, or similar documentation tasks.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>docx</name>
|
|
||||||
<description>"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>frontend-design</name>
|
|
||||||
<description>Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>internal-comms</name>
|
|
||||||
<description>A set of resources to help me write all kinds of internal communications, using the formats that my company likes to use. Claude should use this skill whenever asked to write some sort of internal communications (status reports, leadership updates, 3P updates, company newsletters, FAQs, incident reports, project updates, etc.).</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>mcp-builder</name>
|
|
||||||
<description>Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK).</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>pdf</name>
|
|
||||||
<description>Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>pptx</name>
|
|
||||||
<description>"Presentation creation, editing, and analysis. When Claude needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks"</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>skill-creator</name>
|
|
||||||
<description>Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>slack-gif-creator</name>
|
|
||||||
<description>Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>theme-factory</name>
|
|
||||||
<description>Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>web-artifacts-builder</name>
|
|
||||||
<description>Suite of tools for creating elaborate, multi-component claude.ai HTML artifacts using modern frontend web technologies (React, Tailwind CSS, shadcn/ui). Use for complex artifacts requiring state management, routing, or shadcn/ui components - not for simple single-file HTML/JSX artifacts.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>webapp-testing</name>
|
|
||||||
<description>Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
<skill>
|
|
||||||
<name>xlsx</name>
|
|
||||||
<description>"Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas"</description>
|
|
||||||
<location>project</location>
|
|
||||||
</skill>
|
|
||||||
|
|
||||||
</available_skills>
|
|
||||||
|
|
||||||
<!-- SKILLS_TABLE_END -->
|
|
||||||
|
|
||||||
</skills_system>
|
|
||||||
@@ -2,6 +2,53 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.11.1](https://github.com/Draculabo/AntigravityManager/compare/v0.11.0...v0.11.1) (2026-04-18)
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* add unit label to AI credit display ([6577f1c](https://github.com/Draculabo/AntigravityManager/commit/6577f1c51fd155004b81f6171eb5feffa2448128))
|
||||||
|
* remaining localization gaps ([#156](https://github.com/Draculabo/AntigravityManager/issues/156)) ([7093c98](https://github.com/Draculabo/AntigravityManager/commit/7093c982ba790380cda7d1e8a53891970df6b3cf))
|
||||||
|
* remove AI credit refresh time scraping ([3e1e10b](https://github.com/Draculabo/AntigravityManager/commit/3e1e10b2a580ebf5a64b9a8982bd812a6a1f2924))
|
||||||
|
|
||||||
|
### 📝 Documentation
|
||||||
|
|
||||||
|
* **repo:** update project docs ([440a934](https://github.com/Draculabo/AntigravityManager/commit/440a9342800f244287c758b1a77a05fac9096df7))
|
||||||
|
|
||||||
|
### ♻️ Code Refactoring
|
||||||
|
|
||||||
|
* **ipc:** migrate renderer IPC client to official oRPC client ([d0c282d](https://github.com/Draculabo/AntigravityManager/commit/d0c282dffff24bb74a81cfa523f2f72f949bf8f9))
|
||||||
|
* standardize runtime guard patterns with lodash-es ([#154](https://github.com/Draculabo/AntigravityManager/issues/154)) ([bc35b96](https://github.com/Draculabo/AntigravityManager/commit/bc35b96b5d2d873cf0f187a57de695aedc32108f))
|
||||||
|
|
||||||
|
## [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)
|
## [0.10.0](https://github.com/Draculabo/AntigravityManager/compare/v0.9.2...v0.10.0) (2026-02-19)
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
- [Quick Start](#-quick-start)
|
- [Quick Start](#-quick-start)
|
||||||
- [Tech Stack](#️-tech-stack)
|
- [Tech Stack](#️-tech-stack)
|
||||||
- [Development](#-development)
|
- [Development](#-development)
|
||||||
|
- [Debugging Docs](#-debugging-docs)
|
||||||
- [FAQ](#-faq)
|
- [FAQ](#-faq)
|
||||||
- [Contributing](#-contributing)
|
- [Contributing](#-contributing)
|
||||||
- [License](#-license)
|
- [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
|
- **🖥️ System Tray** - Background mode with tray icon and right-click menu
|
||||||
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
- **🔗 IDE Sync** - Automatically scan and import accounts from IDE's `state.vscdb`
|
||||||
- **📦 Batch Operations** - Batch refresh and delete multiple accounts
|
- **📦 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
|
- **🎨 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) |
|
| Windows (x64/ARM64) | [.exe installer](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||||
| macOS | [.dmg 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) |
|
| 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
|
### 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
|
## ❓ FAQ
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
+63
-1
@@ -50,6 +50,7 @@
|
|||||||
- [快速开始](#-快速开始)
|
- [快速开始](#-快速开始)
|
||||||
- [技术栈](#️-技术栈)
|
- [技术栈](#️-技术栈)
|
||||||
- [开发指南](#-开发指南)
|
- [开发指南](#-开发指南)
|
||||||
|
- [调试文档](#-调试文档)
|
||||||
- [常见问题](#-常见问题)
|
- [常见问题](#-常见问题)
|
||||||
- [贡献指南](#-贡献指南)
|
- [贡献指南](#-贡献指南)
|
||||||
- [许可证](#-许可证)
|
- [许可证](#-许可证)
|
||||||
@@ -158,7 +159,12 @@
|
|||||||
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
- **🖥️ 系统托盘** - 后台运行,托盘图标和右键菜单
|
||||||
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
- **🔗 IDE 同步** - 自动扫描导入 IDE 的 `state.vscdb` 账号
|
||||||
- **📦 批量操作** - 批量刷新和删除多个账号
|
- **📦 批量操作** - 批量刷新和删除多个账号
|
||||||
- **🌏 国际化** - 多语言支持(English / 中文)
|
- **📤 JSON 导出/导入** - 导出和导入账号池,支持架构验证和去重
|
||||||
|
- **🔔 桌面通知** - 模型配额低于设定阈值时自动提醒
|
||||||
|
- **🌐 单账号代理** - 为每个账号单独配置 HTTP/SOCKS5 代理
|
||||||
|
- **📊 智能排序** - 按最近使用、总体配额或特定模型组排序
|
||||||
|
- **📋 紧凑布局** - 水平密集视图,最大化可见账号数量
|
||||||
|
- **🌏 国际化** - 多语言支持(English / 中文 / Русский)
|
||||||
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
- **🎨 现代 UI** - 基于 React、TailwindCSS 和 Shadcn UI 构建
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -187,6 +193,53 @@
|
|||||||
| Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
| Windows (x64/ARM64) | [.exe 安装包](https://github.com/Draculabo/AntigravityManager/releases/latest) |
|
||||||
| macOS | [.dmg 安装包](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) |
|
| 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>
|
<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
+326
-712
File diff suppressed because it is too large.
Load diff
+35
-28
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"productName": "Antigravity Manager",
|
"productName": "Antigravity Manager",
|
||||||
"version": "0.10.0",
|
"version": "0.11.1",
|
||||||
"description": "Antigravity Manager - Electron App",
|
"description": "Antigravity Manager - Electron App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -23,18 +23,18 @@
|
|||||||
"author": "Draculabo",
|
"author": "Draculabo",
|
||||||
"license": "CC-BY-NC-SA-4.0",
|
"license": "CC-BY-NC-SA-4.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.10.2",
|
"@electron-forge/cli": "^7.11.1",
|
||||||
"@electron-forge/maker-deb": "^7.10.2",
|
"@electron-forge/maker-deb": "^7.11.1",
|
||||||
"@electron-forge/maker-dmg": "^7.10.2",
|
"@electron-forge/maker-dmg": "^7.11.1",
|
||||||
"@electron-forge/maker-rpm": "^7.10.2",
|
"@electron-forge/maker-rpm": "^7.11.1",
|
||||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||||
"@electron-forge/maker-wix": "^7.10.2",
|
"@electron-forge/maker-wix": "^7.11.1",
|
||||||
"@electron-forge/maker-zip": "^7.10.2",
|
"@electron-forge/maker-zip": "^7.11.1",
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
|
||||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
"@electron-forge/plugin-fuses": "^7.11.1",
|
||||||
"@electron-forge/plugin-vite": "^7.10.2",
|
"@electron-forge/plugin-vite": "^7.11.1",
|
||||||
"@electron-forge/publisher-github": "^7.10.2",
|
"@electron-forge/publisher-github": "^7.11.1",
|
||||||
"@electron-forge/shared-types": "^7.8.1",
|
"@electron-forge/shared-types": "^7.11.1",
|
||||||
"@electron/fuses": "~1.8.0",
|
"@electron/fuses": "~1.8.0",
|
||||||
"@electron/rebuild": "^3.7.1",
|
"@electron/rebuild": "^3.7.1",
|
||||||
"@eslint/compat": "^1.4.1",
|
"@eslint/compat": "^1.4.1",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@semantic-release/changelog": "^6.0.3",
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
"@semantic-release/git": "^10.0.1",
|
"@semantic-release/git": "^10.0.1",
|
||||||
"@semantic-release/github": "^12.0.2",
|
"@semantic-release/github": "^12.0.6",
|
||||||
"@sentry/vite-plugin": "^4.8.0",
|
"@sentry/vite-plugin": "^4.8.0",
|
||||||
"@swc/core": "^1.15.8",
|
"@swc/core": "^1.15.8",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
@@ -73,12 +73,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"happy-dom": "^20.8.9",
|
||||||
"icon-gen": "^5.0.0",
|
"icon-gen": "^5.0.0",
|
||||||
"jsdom": "^27.1.0",
|
"jsdom": "^27.1.0",
|
||||||
"node-addon-api": "^8.3.1",
|
"node-addon-api": "^8.3.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"semantic-release": "^25.0.2",
|
"semantic-release": "^25.0.3",
|
||||||
"semantic-release-config-gitmoji": "^1.5.3",
|
"semantic-release-config-gitmoji": "^1.5.3",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
@@ -93,13 +94,13 @@
|
|||||||
"@grpc/grpc-js": "^1.14.3",
|
"@grpc/grpc-js": "^1.14.3",
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||||
"@nestjs/common": "^11.1.10",
|
"@nestjs/common": "^11.1.18",
|
||||||
"@nestjs/core": "^11.1.10",
|
"@nestjs/core": "^11.1.18",
|
||||||
"@nestjs/microservices": "^11.1.11",
|
"@nestjs/microservices": "^11.1.18",
|
||||||
"@nestjs/platform-express": "^11.1.11",
|
"@nestjs/platform-express": "^11.1.18",
|
||||||
"@nestjs/platform-fastify": "^11.1.10",
|
"@nestjs/platform-fastify": "^11.1.18",
|
||||||
"@orpc/client": "^1.10.4",
|
"@orpc/client": "^1.13.14",
|
||||||
"@orpc/server": "^1.11.3",
|
"@orpc/server": "^1.13.14",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
@@ -121,22 +122,21 @@
|
|||||||
"@tanstack/react-router": "^1.139.3",
|
"@tanstack/react-router": "^1.139.3",
|
||||||
"@tanstack/react-router-devtools": "^1.135.0",
|
"@tanstack/react-router-devtools": "^1.135.0",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.15.0",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.39.1",
|
"drizzle-orm": "^0.45.2",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.8.4",
|
||||||
"find-process": "^2.0.0",
|
"find-process": "^2.0.0",
|
||||||
"happy-dom": "^20.0.10",
|
|
||||||
"i18next": "^25.6.3",
|
"i18next": "^25.6.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"keytar": "^7.9.0",
|
"keytar": "^7.9.0",
|
||||||
"lodash-es": "^4.17.22",
|
"lodash-es": "^4.18.1",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -153,5 +153,12 @@
|
|||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.1.12"
|
"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',
|
'@semantic-release/git',
|
||||||
{
|
{
|
||||||
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
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 { ipc } from '@/ipc/manager';
|
||||||
import type { DeviceProfile } from '@/types/account';
|
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);
|
return ipc.client.cloud.addGoogleAccount(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export function deleteCloudAccount(input: { accountId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function refreshAccountQuota(input: { accountId: string }) {
|
export function refreshAccountQuota(input: { accountId: string }) {
|
||||||
|
console.log(`[Action] Calling refreshAccountQuota for: ${input.accountId}`);
|
||||||
return ipc.client.cloud.refreshAccountQuota(input);
|
return ipc.client.cloud.refreshAccountQuota(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +39,29 @@ export function syncLocalAccount() {
|
|||||||
return ipc.client.cloud.syncLocalAccount();
|
return ipc.client.cloud.syncLocalAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startAuthFlow() {
|
export interface OAuthClientDescriptor {
|
||||||
return ipc.client.cloud.startAuthFlow();
|
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() {
|
export function getSwitchStatus() {
|
||||||
@@ -88,3 +111,26 @@ export function deleteCloudIdentityProfileRevision(input: {
|
|||||||
export function openCloudIdentityStorageFolder() {
|
export function openCloudIdentityStorageFolder() {
|
||||||
return ipc.client.cloud.openIdentityStorageFolder();
|
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,20 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { MoreVertical, Trash2, RefreshCw } from 'lucide-react';
|
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 { enUS, zhCN, ru, vi } from 'date-fns/locale';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
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,
|
||||||
|
vi: vi,
|
||||||
|
};
|
||||||
|
|
||||||
interface AccountCardProps {
|
interface AccountCardProps {
|
||||||
account: Account;
|
account: Account;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
@@ -35,8 +44,8 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
const [transform, setTransform] = useState('');
|
const [cardTransform, setCardTransform] = useState('');
|
||||||
const [glarePosition, setGlarePosition] = useState({ x: 50, y: 50 });
|
const [glarePosition, setGlarePosition] = useState(DEFAULT_GLARE_POSITION);
|
||||||
|
|
||||||
const initials = account.name
|
const initials = account.name
|
||||||
? account.name
|
? account.name
|
||||||
@@ -47,38 +56,38 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
: account.email[0].toUpperCase();
|
: account.email[0].toUpperCase();
|
||||||
|
|
||||||
// 处理鼠标移动,计算 3D 旋转角度
|
// Handle pointer movement and calculate 3D rotation angles
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!cardRef.current) return;
|
if (!cardRef.current) return;
|
||||||
|
|
||||||
const rect = cardRef.current.getBoundingClientRect();
|
const rect = cardRef.current.getBoundingClientRect();
|
||||||
const centerX = rect.left + rect.width / 2;
|
const centerX = rect.left + rect.width / 2;
|
||||||
const centerY = rect.top + rect.height / 2;
|
const centerY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
// 计算鼠标相对于卡片中心的位置 (-1 到 1)
|
// Calculate pointer position relative to the card center (-1 to 1)
|
||||||
const relativeX = (e.clientX - centerX) / (rect.width / 2);
|
const relativeX = (event.clientX - centerX) / (rect.width / 2);
|
||||||
const relativeY = (e.clientY - centerY) / (rect.height / 2);
|
const relativeY = (event.clientY - centerY) / (rect.height / 2);
|
||||||
|
|
||||||
// 最大旋转角度
|
// Maximum rotation angle
|
||||||
const maxRotation = 15;
|
const maxRotation = 15;
|
||||||
const rotateX = -relativeY * maxRotation; // 上下倾斜
|
const rotateX = -relativeY * maxRotation; // Vertical tilt
|
||||||
const rotateY = relativeX * maxRotation; // 左右倾斜
|
const rotateY = relativeX * maxRotation; // Horizontal tilt
|
||||||
|
|
||||||
setTransform(
|
setCardTransform(
|
||||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`,
|
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 更新光泽位置
|
// Update glare position
|
||||||
setGlarePosition({
|
setGlarePosition({
|
||||||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
x: ((event.clientX - rect.left) / rect.width) * 100,
|
||||||
y: ((e.clientY - rect.top) / rect.height) * 100,
|
y: ((event.clientY - rect.top) / rect.height) * 100,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 鼠标离开时重置
|
// Reset when pointer leaves
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
setTransform('perspective(1000px) rotateX(0deg) rotateY(0deg) scale3d(1, 1, 1)');
|
setCardTransform(DEFAULT_CARD_TRANSFORM);
|
||||||
setGlarePosition({ x: 50, y: 50 });
|
setGlarePosition(DEFAULT_GLARE_POSITION);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,18 +96,18 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
style={{
|
style={{
|
||||||
transform,
|
transform: cardTransform,
|
||||||
transformStyle: 'preserve-3d',
|
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"
|
className="relative"
|
||||||
>
|
>
|
||||||
{/* 光泽效果层 */}
|
{/* Glare overlay */}
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 z-10 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
className="pointer-events-none absolute inset-0 z-10 rounded-xl opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||||
style={{
|
style={{
|
||||||
background: `radial-gradient(circle at ${glarePosition.x}% ${glarePosition.y}%, rgba(255,255,255,0.15) 0%, transparent 60%)`,
|
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 +146,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
{t('account.lastUsed', {
|
{t('account.lastUsed', {
|
||||||
time: formatDistanceToNow(new Date(account.last_used), {
|
time: formatDistanceToNow(new Date(account.last_used), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
locale: { en: enUS, 'zh-CN': zhCN, ru: ru }[i18n.language] || enUS,
|
locale: DATE_LOCALE_MAP[i18n.language] || enUS,
|
||||||
}),
|
}),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
@@ -154,7 +163,7 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
<span className="sr-only">Open menu</span>
|
<span className="sr-only">{t('common.openMenu')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|||||||
+594
-154
@@ -1,4 +1,4 @@
|
|||||||
import { CloudAccount } from '@/types/cloudAccount';
|
import { CloudAccount, CloudQuotaModelInfo } from '@/types/cloudAccount';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -17,19 +17,142 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
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 { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react';
|
||||||
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
import { useProviderGrouping } from '@/hooks/useProviderGrouping';
|
import { useProviderGrouping } from '@/hooks/useProviderGrouping';
|
||||||
import { ProviderGroup } from '@/components/ProviderGroup';
|
import { ProviderGroup } from '@/components/ProviderGroup';
|
||||||
|
import {
|
||||||
|
clampQuotaPercentage,
|
||||||
|
formatAiCreditsAmount,
|
||||||
|
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 {
|
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||||
percentage: number;
|
|
||||||
resetTime: string;
|
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 formatCreditsExpiry(expiryDate: string): string {
|
||||||
|
if (!expiryDate) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(expiryDate);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return expiryDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
interface CloudAccountCardProps {
|
||||||
account: CloudAccount;
|
account: CloudAccount;
|
||||||
onRefresh: (id: string) => void;
|
onRefresh: (id: string) => void;
|
||||||
@@ -56,122 +179,67 @@ export function CloudAccountCard({
|
|||||||
isSwitching,
|
isSwitching,
|
||||||
}: CloudAccountCardProps) {
|
}: CloudAccountCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config, saveConfig } = useAppConfig();
|
||||||
const {
|
const {
|
||||||
enabled: providerGroupingsEnabled,
|
enabled: providerGroupingsEnabled,
|
||||||
getAccountStats,
|
getAccountStats,
|
||||||
isProviderCollapsed,
|
isProviderCollapsed,
|
||||||
toggleProviderCollapse,
|
toggleProviderCollapse,
|
||||||
} = useProviderGrouping();
|
} = useProviderGrouping();
|
||||||
|
const setAccountProxy = useSetAccountProxy();
|
||||||
|
const [proxyUrl, setProxyUrl] = useState(account.proxy_url || '');
|
||||||
|
const [proxySaved, setProxySaved] = useState(false);
|
||||||
|
|
||||||
// Helpers to get quota color
|
const getQuotaTextColorClass = (percentage: number) => {
|
||||||
const getQuotaColor = (percentage: number) => {
|
const quotaStatus = getQuotaStatus(percentage);
|
||||||
if (percentage > 80) return 'text-green-500';
|
return QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||||
if (percentage > 20) return 'text-yellow-500';
|
|
||||||
return 'text-red-500';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getQuotaBarColor = (percentage: number) => {
|
const getQuotaBarColorClass = (percentage: number) => {
|
||||||
if (percentage > 80) return 'bg-emerald-500';
|
const quotaStatus = getQuotaStatus(percentage);
|
||||||
if (percentage > 20) return 'bg-amber-500';
|
return QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus];
|
||||||
return 'bg-rose-500';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getQuotaLabel = (percentage: number) => {
|
const formatQuotaLabel = (percentage: number) => {
|
||||||
if (percentage === 0) {
|
if (percentage === 0) {
|
||||||
return t('cloud.card.rateLimitedQuota');
|
return t('cloud.card.rateLimitedQuota');
|
||||||
}
|
}
|
||||||
return `${percentage}%`;
|
return `${percentage}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimeRemaining = (dateStr: string) => {
|
const formatResetTimeLabelText = (resetTime?: string) => {
|
||||||
const targetDate = new Date(dateStr);
|
return formatResetTimeLabel(resetTime, {
|
||||||
if (Number.isNaN(targetDate.getTime())) return null;
|
prefix: t('cloud.card.resetPrefix'),
|
||||||
|
unknown: t('cloud.card.resetUnknown'),
|
||||||
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 getResetTimeLabel = (resetTime?: string) => {
|
const formatResetTimeTitleText = (resetTime?: string) => {
|
||||||
if (!resetTime) return t('cloud.card.resetUnknown');
|
return formatResetTimeTitle(resetTime, t('cloud.card.resetTime'));
|
||||||
const remaining = formatTimeRemaining(resetTime);
|
|
||||||
if (!remaining) return t('cloud.card.resetUnknown');
|
|
||||||
return `${t('cloud.card.resetPrefix')}: ${remaining}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getResetTimeTitle = (resetTime?: string) => {
|
const allModelEntries = Object.entries(account.quota?.models || {}) as ModelQuotaEntry[];
|
||||||
if (!resetTime) return undefined;
|
|
||||||
const resetDate = new Date(resetTime);
|
|
||||||
if (Number.isNaN(resetDate.getTime())) return undefined;
|
|
||||||
return `${t('cloud.card.resetTime')}: ${resetDate.toLocaleString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Logic for model groups ---
|
const visibleModelEntries = Object.entries(account.quota?.models || {}).filter(
|
||||||
const rawModels = Object.entries(account.quota?.models || {}).filter(
|
|
||||||
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
([modelName]) => config?.model_visibility?.[modelName] !== false,
|
||||||
);
|
) as ModelQuotaEntry[];
|
||||||
|
|
||||||
// Group Gemini 3 Pro Low/High if both exist
|
const mergedModelQuotas = mergeGeminiProQuotaEntries(visibleModelEntries);
|
||||||
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'));
|
|
||||||
|
|
||||||
for (const [name, info] of rawModels) {
|
const geminiModels = Object.entries(mergedModelQuotas)
|
||||||
if (name.includes('gemini-3-pro-low') && hasHigh) continue;
|
.filter(([name]) => name.includes('gemini') && !GEMINI_LEGACY_MODEL_PATTERN.test(name))
|
||||||
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))
|
|
||||||
.sort((a, b) => b[1].percentage - a[1].percentage);
|
.sort((a, b) => b[1].percentage - a[1].percentage);
|
||||||
|
|
||||||
const claudeModels = Object.entries(processedModels)
|
const claudeModels = Object.entries(mergedModelQuotas)
|
||||||
.filter(([name]) => name.includes('claude'))
|
.filter(([name]) => name.includes('claude'))
|
||||||
.sort((a, b) => b[1].percentage - a[1].percentage);
|
.sort((a, b) => b[1].percentage - a[1].percentage);
|
||||||
|
|
||||||
const hasHighTier = geminiModels.some(
|
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) => {
|
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
|
||||||
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][]) => {
|
|
||||||
if (models.length === 0) return null;
|
if (models.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={title} className="space-y-1">
|
<div key={title} className="space-y-1">
|
||||||
@@ -190,25 +258,25 @@ export function CloudAccountCard({
|
|||||||
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
||||||
title={modelName}
|
title={modelName}
|
||||||
>
|
>
|
||||||
{formatModelName(modelName)}
|
{formatModelDisplayName(modelName)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col items-end gap-0.5">
|
<div className="flex flex-col items-end gap-0.5">
|
||||||
<span
|
<span
|
||||||
className="text-muted-foreground text-[9px] leading-none opacity-80"
|
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>
|
</span>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span
|
<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}%
|
{info.percentage}%
|
||||||
</span>
|
</span>
|
||||||
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
|
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`}
|
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||||
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
|
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,6 +287,67 @@ 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 validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||||
|
account.status,
|
||||||
|
account.status_reason,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<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' : ''}`}
|
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 +381,75 @@ export function CloudAccountCard({
|
|||||||
{account.name || t('cloud.card.unknown')}
|
{account.name || t('cloud.card.unknown')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||||
|
|
||||||
|
{shouldShowAiCredits && aiCredits && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-blue-500">
|
||||||
|
<span>
|
||||||
|
{t('cloud.card.aiCreditsValue', {
|
||||||
|
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{aiCredits.expiryDate && (
|
||||||
|
<span className="text-muted-foreground opacity-70">
|
||||||
|
·{' '}
|
||||||
|
{t('cloud.card.creditsExpiry', {
|
||||||
|
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||||
@@ -293,7 +490,11 @@ export function CloudAccountCard({
|
|||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
variant={
|
||||||
|
account.status === 'rate_limited' || account.status === 'expired'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{account.provider.toUpperCase()}
|
{account.provider.toUpperCase()}
|
||||||
@@ -303,9 +504,9 @@ export function CloudAccountCard({
|
|||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{account.status === 'rate_limited' && (
|
{validationBlockedStatusLabel && (
|
||||||
<span className="text-destructive text-xs font-medium">
|
<span className="text-destructive text-xs font-medium">
|
||||||
{t('cloud.card.rateLimited')}
|
{validationBlockedStatusLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -344,66 +545,15 @@ export function CloudAccountCard({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{providerGroupingsEnabled ? (
|
{providerGroupingsEnabled ? (
|
||||||
(() => {
|
providerGroupedQuotaSection
|
||||||
const accountStats = getAccountStats(account);
|
) : hasVisibleQuotaModels ? (
|
||||||
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 ? (
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{renderModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
{renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
||||||
<div className="pt-1" />
|
<div className="pt-1" />
|
||||||
{renderModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
{renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
emptyQuotaState
|
||||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
|
||||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -413,7 +563,297 @@ export function CloudAccountCard({
|
|||||||
{t('cloud.card.used')}{' '}
|
{t('cloud.card.used')}{' '}
|
||||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||||
</span>
|
</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>
|
</CardFooter>
|
||||||
</Card>
|
</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 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-blue-500">
|
||||||
|
{t('cloud.card.aiCreditsValue', {
|
||||||
|
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||||
|
})}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+625
-139
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
|||||||
import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
|
import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
@@ -48,11 +48,14 @@ function formatError(error: unknown): string {
|
|||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProfile(profile?: DeviceProfile): JSX.Element {
|
function renderProfile(
|
||||||
|
profile: DeviceProfile | undefined,
|
||||||
|
t: ReturnType<typeof useTranslation>['t'],
|
||||||
|
): JSX.Element {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
|
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
|
||||||
N/A
|
{t('common.notAvailable')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,41 +98,44 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]);
|
const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]);
|
||||||
const showLoadingPlaceholder = initialLoading && !snapshot;
|
const showLoadingPlaceholder = initialLoading && !snapshot;
|
||||||
|
|
||||||
const refreshProfiles = async (options?: { silent?: boolean }) => {
|
const refreshProfiles = useCallback(
|
||||||
if (!account) {
|
async (options?: { silent?: boolean }) => {
|
||||||
setSnapshot(null);
|
if (!account) {
|
||||||
return;
|
setSnapshot(null);
|
||||||
}
|
return;
|
||||||
const silent = options?.silent === true;
|
|
||||||
if (silent) {
|
|
||||||
setRefreshing(true);
|
|
||||||
} else {
|
|
||||||
setInitialLoading(true);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await getCloudIdentityProfiles({ accountId: account.id });
|
|
||||||
setSnapshot(result);
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
|
|
||||||
description: formatError(error),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
if (silent) {
|
|
||||||
setRefreshing(false);
|
|
||||||
} else {
|
|
||||||
setInitialLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
const silent = options?.silent === true;
|
||||||
};
|
if (silent) {
|
||||||
|
setRefreshing(true);
|
||||||
|
} else {
|
||||||
|
setInitialLoading(true);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await getCloudIdentityProfiles({ accountId: account.id });
|
||||||
|
setSnapshot(result);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: t('cloud.toast.actionFailed'),
|
||||||
|
description: formatError(error),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (silent) {
|
||||||
|
setRefreshing(false);
|
||||||
|
} else {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[account, t, toast],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshProfiles();
|
void refreshProfiles();
|
||||||
}, [open, account?.id]);
|
}, [open, refreshProfiles]);
|
||||||
|
|
||||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||||
if (actionLockRef.current) {
|
if (actionLockRef.current) {
|
||||||
@@ -142,7 +148,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
await refreshProfiles({ silent: true });
|
await refreshProfiles({ silent: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
|
title: t('cloud.toast.actionFailed'),
|
||||||
description: formatError(error),
|
description: formatError(error),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -247,7 +253,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
|
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{renderProfile(previewProfile)}
|
{renderProfile(previewProfile, t)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -295,7 +301,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
{t('cloud.identity.loading')}
|
{t('cloud.identity.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
renderProfile(snapshot?.currentStorage)
|
renderProfile(snapshot?.currentStorage, t)
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -313,7 +319,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
{t('cloud.identity.loading')}
|
{t('cloud.identity.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
renderProfile(snapshot?.boundProfile)
|
renderProfile(snapshot?.boundProfile, t)
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -353,7 +359,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderProfile(version.profile)}
|
{renderProfile(version.profile, t)}
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -402,15 +408,13 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
|
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm">
|
<CardTitle className="text-sm">{t('cloud.identity.baseline')}</CardTitle>
|
||||||
{t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{showLoadingPlaceholder ? (
|
{showLoadingPlaceholder ? (
|
||||||
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
|
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
renderProfile(snapshot?.baseline)
|
renderProfile(snapshot?.baseline, t)
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||||
@@ -10,81 +10,110 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Loader2, Search, RotateCcw, Save } from 'lucide-react';
|
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 { t } = useTranslation();
|
||||||
const { config, saveConfig } = useAppConfig();
|
const { config, saveConfig } = useAppConfig();
|
||||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [modelVisibility, setModelVisibility] = useState<Record<string, boolean>>({});
|
const [modelVisibilityMap, setModelVisibilityMap] = useState<Record<string, boolean>>({});
|
||||||
const [providerGroupingsEnabled, setProviderGroupingsEnabled] = useState(false);
|
const [providerGroupingEnabled, setProviderGroupingEnabled] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSavingSettings, setIsSavingSettings] = useState(false);
|
||||||
|
|
||||||
// Initialize model visibility and provider groupings from config
|
// Initialize model visibility and provider groupings from config
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.model_visibility) {
|
if (config?.model_visibility) {
|
||||||
setModelVisibility(config.model_visibility);
|
setModelVisibilityMap(config.model_visibility);
|
||||||
}
|
}
|
||||||
if (config?.provider_groupings_enabled !== undefined) {
|
if (config?.provider_groupings_enabled !== undefined) {
|
||||||
setProviderGroupingsEnabled(config.provider_groupings_enabled);
|
setProviderGroupingEnabled(config.provider_groupings_enabled);
|
||||||
}
|
}
|
||||||
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
||||||
|
|
||||||
// Get all unique models from all accounts
|
// Get all unique models from all accounts
|
||||||
const allModels = useMemo(() => {
|
const availableModelIds = useMemo(() => {
|
||||||
if (!accounts) return [];
|
return collectAvailableModelIds(accounts);
|
||||||
|
|
||||||
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();
|
|
||||||
}, [accounts]);
|
}, [accounts]);
|
||||||
|
|
||||||
// Filter models based on search term
|
// Filter models based on search term
|
||||||
const filteredModels = useMemo(() => {
|
const filteredModelIds = useMemo(() => {
|
||||||
return allModels.filter((model) => model.toLowerCase().includes(searchTerm.toLowerCase()));
|
return filterModelIdsByQuery(availableModelIds, searchQuery);
|
||||||
}, [allModels, searchTerm]);
|
}, [availableModelIds, searchQuery]);
|
||||||
|
|
||||||
// Handle model visibility toggle
|
const hiddenModelCount = useMemo(() => {
|
||||||
const handleModelToggle = (modelName: string, checked: boolean) => {
|
return sumBy(values(modelVisibilityMap), (isVisible) => (isVisible === false ? 1 : 0));
|
||||||
setModelVisibility((prev) => ({
|
}, [modelVisibilityMap]);
|
||||||
...prev,
|
|
||||||
[modelName]: checked,
|
const isModelVisible = (modelId: string): boolean => {
|
||||||
}));
|
return modelVisibilityMap[modelId] !== false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset to defaults (all models visible)
|
// Reset to defaults (all models visible)
|
||||||
const handleReset = () => {
|
const resetVisibilityOverrides = () => {
|
||||||
setModelVisibility({});
|
setModelVisibilityMap({});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save configuration
|
// Save configuration
|
||||||
const handleSave = async () => {
|
const saveVisibilitySettings = async () => {
|
||||||
if (!config) return;
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
await saveConfig({
|
const nextConfig = {
|
||||||
...config,
|
...config,
|
||||||
model_visibility: modelVisibility,
|
model_visibility: modelVisibilityMap,
|
||||||
provider_groupings_enabled: providerGroupingsEnabled,
|
provider_groupings_enabled: providerGroupingEnabled,
|
||||||
});
|
};
|
||||||
|
await saveConfig(nextConfig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save model visibility settings:', error);
|
console.error('Failed to save model visibility settings:', error);
|
||||||
} finally {
|
} 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) {
|
if (accountsLoading) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -101,7 +130,9 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<span>{t('settings.modelVisibility.title')}</span>
|
<span>{t('settings.modelVisibility.title')}</span>
|
||||||
<Badge variant="secondary">{filteredModels.length} models</Badge>
|
<Badge variant="secondary">
|
||||||
|
{t('settings.providerGroupings.models', { count: filteredModelIds.length })}
|
||||||
|
</Badge>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
|
<CardDescription>{t('settings.modelVisibility.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -111,8 +142,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" />
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('settings.modelVisibility.searchPlaceholder')}
|
placeholder={t('settings.modelVisibility.searchPlaceholder')}
|
||||||
value={searchTerm}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(event) => handleSearchQueryChange(event.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,37 +160,39 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
|||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="provider-groupings"
|
id="provider-groupings"
|
||||||
checked={providerGroupingsEnabled}
|
checked={providerGroupingEnabled}
|
||||||
onCheckedChange={setProviderGroupingsEnabled}
|
onCheckedChange={handleProviderGroupingToggle}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model List */}
|
{/* Model List */}
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto rounded-lg border p-4">
|
<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">
|
<div className="text-muted-foreground py-8 text-center">
|
||||||
{searchTerm
|
{searchQuery
|
||||||
? t('settings.modelVisibility.noModelsFound')
|
? t('settings.modelVisibility.noModelsFound')
|
||||||
: t('settings.modelVisibility.noModels')}
|
: t('settings.modelVisibility.noModels')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredModels.map((modelName) => {
|
filteredModelIds.map((modelId) => {
|
||||||
const isVisible = modelVisibility[modelName] !== false; // Default to true
|
const isVisible = isModelVisible(modelId);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={modelName}
|
key={modelId}
|
||||||
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2"
|
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`model-${modelName}`}
|
id={`model-${modelId}`}
|
||||||
checked={isVisible}
|
checked={isVisible}
|
||||||
onCheckedChange={(checked) => handleModelToggle(modelName, checked as boolean)}
|
onCheckedChange={(checked) =>
|
||||||
|
handleModelVisibilityChange(modelId, checked as boolean)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={`model-${modelName}`}
|
htmlFor={`model-${modelId}`}
|
||||||
className="flex-1 cursor-pointer text-sm font-medium"
|
className="flex-1 cursor-pointer text-sm font-medium"
|
||||||
>
|
>
|
||||||
{modelName}
|
{modelId}
|
||||||
</label>
|
</label>
|
||||||
{!isVisible && (
|
{!isVisible && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
@@ -176,16 +209,26 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
|||||||
<div className="flex gap-2 pt-4">
|
<div className="flex gap-2 pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleReset}
|
onClick={resetVisibilityOverrides}
|
||||||
disabled={Object.keys(modelVisibility).length === 0}
|
disabled={size(modelVisibilityMap) === 0}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
{t('settings.modelVisibility.reset')}
|
{t('settings.modelVisibility.reset')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isSaving} className="flex items-center gap-2">
|
<Button
|
||||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
onClick={saveVisibilitySettings}
|
||||||
{isSaving ? t('settings.modelVisibility.saving') : t('settings.modelVisibility.save')}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,19 +236,18 @@ export const ModelVisibilitySettings: React.FC<ModelVisibilitySettingsProps> = (
|
|||||||
<div className="text-muted-foreground border-t pt-2 text-sm">
|
<div className="text-muted-foreground border-t pt-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>
|
<span>
|
||||||
{t('settings.modelVisibility.totalModels')}: {allModels.length}
|
{t('settings.modelVisibility.totalModels')}: {availableModelIds.length}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{t('settings.modelVisibility.visibleModels')}:{' '}
|
{t('settings.modelVisibility.visibleModels')}:{' '}
|
||||||
{allModels.length - Object.values(modelVisibility).filter((v) => !v).length}
|
{availableModelIds.length - hiddenModelCount}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{t('settings.modelVisibility.hiddenModels')}:{' '}
|
{t('settings.modelVisibility.hiddenModels')}: {hiddenModelCount}
|
||||||
{Object.values(modelVisibility).filter((v) => !v).length}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
@@ -4,16 +4,17 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import type { ProviderStats } from '@/utils/provider-grouping';
|
import type { ProviderStats } from '@/utils/provider-grouping';
|
||||||
|
import { clampQuotaPercentage } from '@/utils/quota-display';
|
||||||
|
|
||||||
interface ProviderGroupProps {
|
interface ProviderGroupProps {
|
||||||
stats: ProviderStats;
|
stats: ProviderStats;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
getQuotaColor: (percentage: number) => string;
|
getQuotaTextColorClass: (percentage: number) => string;
|
||||||
getQuotaBarColor: (percentage: number) => string;
|
getQuotaBarColorClass: (percentage: number) => string;
|
||||||
getQuotaLabel: (percentage: number) => string;
|
formatQuotaLabel: (percentage: number) => string;
|
||||||
getResetTimeLabel: (resetTime?: string) => string;
|
formatResetTimeLabel: (resetTime?: string) => string;
|
||||||
getResetTimeTitle: (resetTime?: string) => string | undefined;
|
formatResetTimeTitle: (resetTime?: string) => string | undefined;
|
||||||
leftLabel: string;
|
leftLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +22,11 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
stats,
|
stats,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
getQuotaColor,
|
getQuotaTextColorClass,
|
||||||
getQuotaBarColor,
|
getQuotaBarColorClass,
|
||||||
getQuotaLabel,
|
formatQuotaLabel,
|
||||||
getResetTimeLabel,
|
formatResetTimeLabel,
|
||||||
getResetTimeTitle,
|
formatResetTimeTitle,
|
||||||
leftLabel,
|
leftLabel,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -69,16 +70,18 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
{earliestReset && (
|
{earliestReset && (
|
||||||
<span
|
<span
|
||||||
className="text-muted-foreground text-[10px]"
|
className="text-muted-foreground text-[10px]"
|
||||||
title={getResetTimeTitle(earliestReset)}
|
title={formatResetTimeTitle(earliestReset)}
|
||||||
>
|
>
|
||||||
{getResetTimeLabel(earliestReset)}
|
{formatResetTimeLabel(earliestReset)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className={`font-mono text-xs font-bold ${getQuotaColor(avgPercentage)}`}>
|
<span
|
||||||
{getQuotaLabel(avgPercentage)}
|
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(avgPercentage)}`}
|
||||||
|
>
|
||||||
|
{formatQuotaLabel(avgPercentage)}
|
||||||
</span>
|
</span>
|
||||||
{avgPercentage > 0 && (
|
{Number.isFinite(avgPercentage) && avgPercentage > 0 && (
|
||||||
<span className="text-muted-foreground text-[10px]">
|
<span className="text-muted-foreground text-[10px]">
|
||||||
{t('settings.providerGroupings.avgLabel')}
|
{t('settings.providerGroupings.avgLabel')}
|
||||||
</span>
|
</span>
|
||||||
@@ -86,8 +89,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(avgPercentage)}`}
|
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
|
||||||
style={{ width: `${Math.max(0, Math.min(100, avgPercentage))}%` }}
|
style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,15 +112,15 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<span
|
<span
|
||||||
className="text-muted-foreground text-[10px] leading-none"
|
className="text-muted-foreground text-[10px] leading-none"
|
||||||
title={getResetTimeTitle(model.resetTime)}
|
title={formatResetTimeTitle(model.resetTime)}
|
||||||
>
|
>
|
||||||
{getResetTimeLabel(model.resetTime)}
|
{formatResetTimeLabel(model.resetTime)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span
|
<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>
|
</span>
|
||||||
{model.percentage > 0 && (
|
{model.percentage > 0 && (
|
||||||
<span className="text-muted-foreground text-[10px]">{leftLabel}</span>
|
<span className="text-muted-foreground text-[10px]">{leftLabel}</span>
|
||||||
@@ -125,8 +128,8 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(model.percentage)}`}
|
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
|
||||||
style={{ width: `${Math.max(0, Math.min(100, model.percentage))}%` }}
|
style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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');
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { isNumber, isObjectLike } from 'lodash-es';
|
||||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -48,21 +49,38 @@ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayNam
|
|||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
const DropdownMenuContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
collisionPadding?: number | Partial<Record<'top' | 'right' | 'bottom' | 'left', number>>;
|
||||||
<DropdownMenuPrimitive.Portal>
|
}
|
||||||
<DropdownMenuPrimitive.Content
|
>(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => {
|
||||||
ref={ref}
|
if (isNumber(collisionPadding)) {
|
||||||
sideOffset={sideOffset}
|
if (collisionPadding < 0) {
|
||||||
className={cn(
|
throw new Error('collisionPadding must be a non-negative number');
|
||||||
'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',
|
} else if (isObjectLike(collisionPadding)) {
|
||||||
className,
|
for (const key of Object.keys(collisionPadding)) {
|
||||||
)}
|
if ((collisionPadding as any)[key] < 0) {
|
||||||
{...props}
|
throw new Error(`collisionPadding.${key} must be a non-negative number`);
|
||||||
/>
|
}
|
||||||
</DropdownMenuPrimitive.Portal>
|
}
|
||||||
));
|
}
|
||||||
|
|
||||||
|
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;
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { toast } from '@/components/ui/use-toast';
|
import { toast } from '@/components/ui/use-toast';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
@@ -9,6 +10,7 @@ import { AppConfig } from '@/types/config';
|
|||||||
const SAVE_DEBOUNCE_MS = 400;
|
const SAVE_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
export function useAppConfig() {
|
export function useAppConfig() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -57,8 +59,8 @@ export function useAppConfig() {
|
|||||||
queryClient.setQueryData(['appConfig'], savedConfig);
|
queryClient.setQueryData(['appConfig'], savedConfig);
|
||||||
lastStableConfigRef.current = savedConfig;
|
lastStableConfigRef.current = savedConfig;
|
||||||
toast({
|
toast({
|
||||||
title: 'Settings saved',
|
title: t('settings.toast.saved.title'),
|
||||||
description: 'Your configuration has been updated.',
|
description: t('settings.toast.saved.description'),
|
||||||
});
|
});
|
||||||
for (const item of pendingBatch) {
|
for (const item of pendingBatch) {
|
||||||
item.resolve();
|
item.resolve();
|
||||||
@@ -72,7 +74,7 @@ export function useAppConfig() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
queryClient.invalidateQueries({ queryKey: ['appConfig'] });
|
||||||
}
|
}
|
||||||
toast({
|
toast({
|
||||||
title: 'Error saving settings',
|
title: t('settings.toast.saveFailed.title'),
|
||||||
description: error.message,
|
description: error.message,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
@@ -80,7 +82,7 @@ export function useAppConfig() {
|
|||||||
item.reject(error);
|
item.reject(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [queryClient, updateConfig]);
|
}, [queryClient, t, updateConfig]);
|
||||||
|
|
||||||
const saveConfig = useCallback(
|
const saveConfig = useCallback(
|
||||||
(newConfig: AppConfig) => {
|
(newConfig: AppConfig) => {
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import {
|
|||||||
addGoogleAccount,
|
addGoogleAccount,
|
||||||
deleteCloudAccount,
|
deleteCloudAccount,
|
||||||
refreshAccountQuota,
|
refreshAccountQuota,
|
||||||
|
setAccountProxy,
|
||||||
|
listOAuthClients,
|
||||||
|
setActiveOAuthClient,
|
||||||
|
type OAuthClientDescriptor,
|
||||||
} from '@/actions/cloud';
|
} from '@/actions/cloud';
|
||||||
import { CloudAccount } from '@/types/cloudAccount';
|
import { CloudAccount } from '@/types/cloudAccount';
|
||||||
|
|
||||||
export const QUERY_KEYS = {
|
export const QUERY_KEYS = {
|
||||||
cloudAccounts: ['cloudAccounts'],
|
cloudAccounts: ['cloudAccounts'],
|
||||||
|
oauthClients: ['oauthClients'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useCloudAccounts() {
|
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() {
|
export function useDeleteCloudAccount() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -120,3 +143,41 @@ export function useSyncLocalAccount() {
|
|||||||
|
|
||||||
import { startAuthFlow } from '@/actions/cloud';
|
import { startAuthFlow } from '@/actions/cloud';
|
||||||
export { startAuthFlow };
|
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 http from 'http';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { ipcContext } from '../context';
|
import { ipcContext } from '../context';
|
||||||
|
import { escapeHtml } from '../../utils/url';
|
||||||
|
|
||||||
export class AuthServer {
|
export class AuthServer {
|
||||||
private static server: http.Server | null = null;
|
private static server: http.Server | null = null;
|
||||||
private static PORT = 8888;
|
private static PORT = 8888;
|
||||||
|
|
||||||
static start() {
|
static async start() {
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
logger.warn('AuthServer: Server already running');
|
logger.warn('AuthServer: Server already running');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.server = http.createServer((req, res) => {
|
const tryPorts = [8888, 8889, 8890, 8891, 8892];
|
||||||
const url = new URL(req.url || '', `http://localhost:${this.PORT}`);
|
let boundPort: number | null = null;
|
||||||
|
|
||||||
if (url.pathname === '/oauth-callback') {
|
for (const port of tryPorts) {
|
||||||
const code = url.searchParams.get('code');
|
try {
|
||||||
const error = url.searchParams.get('error');
|
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) {
|
if (!boundPort) {
|
||||||
logger.info(`AuthServer: Received authorization code: ${code.substring(0, 10)}...`);
|
logger.error('AuthServer: No available ports found for OAuth callback server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Send code to renderer
|
if (boundPort !== 8888) {
|
||||||
if (ipcContext.mainWindow) {
|
logger.warn(`AuthServer: Using fallback port ${boundPort} (default 8888 is in use)`);
|
||||||
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' });
|
this.PORT = boundPort;
|
||||||
res.end(`
|
|
||||||
|
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>
|
<html>
|
||||||
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
<body style="font-family: sans-serif; text-align: center; padding-top: 50px;">
|
||||||
<h1>Login Successful</h1>
|
<h1>Login Successful</h1>
|
||||||
@@ -43,34 +84,46 @@ export class AuthServer {
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
logger.error(`AuthServer: OAuth error: ${error}`);
|
const escapedError = escapeHtml(error);
|
||||||
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
logger.error(`AuthServer: OAuth error: ${escapedError}`);
|
||||||
res.end(`
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<h1>Login Failed</h1>
|
<h1>Login Failed</h1>
|
||||||
<p>Error: ${error}</p>
|
<p>Error: ${escapedError}</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
|
} else {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end('Missing code parameter');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(400);
|
res.writeHead(404);
|
||||||
res.end('Missing code parameter');
|
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) => {
|
static getRedirectUri(): string {
|
||||||
logger.error('AuthServer: Server error', err);
|
return `http://localhost:${this.PORT}/oauth-callback`;
|
||||||
});
|
|
||||||
|
|
||||||
this.server.listen(this.PORT, () => {
|
|
||||||
logger.info(`AuthServer: Listening on http://localhost:${this.PORT}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static stop() {
|
static stop() {
|
||||||
|
|||||||
+508
-47
@@ -1,11 +1,12 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
import { GoogleAPIService, type OAuthClientDescriptor } from '../../services/GoogleAPIService';
|
||||||
import { CloudAccount } from '../../types/cloudAccount';
|
import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
import { shell } from 'electron';
|
import { shell } from 'electron';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { isEmpty, isString } from 'lodash-es';
|
||||||
import { updateTrayMenu } from '../../ipc/tray/handler';
|
import { updateTrayMenu } from '../../ipc/tray/handler';
|
||||||
import {
|
import {
|
||||||
ensureGlobalOriginalFromCurrentStorage,
|
ensureGlobalOriginalFromCurrentStorage,
|
||||||
@@ -20,17 +21,7 @@ import { getAntigravityDbPaths } from '../../utils/paths';
|
|||||||
import { runWithSwitchGuard } from '../../ipc/switchGuard';
|
import { runWithSwitchGuard } from '../../ipc/switchGuard';
|
||||||
import { executeSwitchFlow } from '../../ipc/switchFlow';
|
import { executeSwitchFlow } from '../../ipc/switchFlow';
|
||||||
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
||||||
|
import { classifyAccountStatusFromError, extractErrorMessage } from '../../utils/account-status';
|
||||||
// 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(' ');
|
|
||||||
|
|
||||||
// Helper to update tray
|
// Helper to update tray
|
||||||
function notifyTrayUpdate(account: CloudAccount) {
|
function notifyTrayUpdate(account: CloudAccount) {
|
||||||
@@ -44,15 +35,232 @@ 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 (!isString(projectId)) {
|
||||||
|
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 {
|
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 (isString(preferredClientKey) && !isEmpty(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 =
|
||||||
|
!isString(account.token.project_id) || isEmpty(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 (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
|
||||||
|
setActiveOAuthClient(oauthClientKey);
|
||||||
|
} else {
|
||||||
|
hydrateActiveOAuthClientFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Exchange code for tokens
|
// 1. Exchange code for tokens
|
||||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode);
|
const tokenResp = await GoogleAPIService.exchangeCode(authCode, undefined, oauthClientKey);
|
||||||
|
|
||||||
// 2. Get User Info
|
// 2. Get User Info
|
||||||
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
|
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 now = Math.floor(Date.now() / 1000);
|
||||||
const account: CloudAccount = {
|
const account: CloudAccount = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@@ -62,11 +270,13 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
|||||||
avatar_url: userInfo.picture,
|
avatar_url: userInfo.picture,
|
||||||
token: {
|
token: {
|
||||||
access_token: tokenResp.access_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,
|
expires_in: tokenResp.expires_in,
|
||||||
expiry_timestamp: now + tokenResp.expires_in,
|
expiry_timestamp: now + tokenResp.expires_in,
|
||||||
token_type: tokenResp.token_type,
|
token_type: tokenResp.token_type,
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
|
oauth_client_key: tokenResp.oauth_client_key,
|
||||||
|
is_gcp_tos: true,
|
||||||
},
|
},
|
||||||
created_at: now,
|
created_at: now,
|
||||||
last_used: now,
|
last_used: now,
|
||||||
@@ -76,14 +286,25 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
|||||||
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
logger.warn(`No refresh token received for ${account.email}. Account will expire in 1 hour.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save to DB
|
|
||||||
await CloudAccountRepo.addAccount(account);
|
await CloudAccountRepo.addAccount(account);
|
||||||
|
|
||||||
// 5. Initial Quota Check (Async, best effort)
|
|
||||||
try {
|
try {
|
||||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
||||||
|
try {
|
||||||
|
const aiCredits = await GoogleAPIService.fetchAICredits(
|
||||||
|
account.token.access_token,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
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;
|
account.quota = quota;
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||||
notifyTrayUpdate(account);
|
notifyTrayUpdate(account);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to fetch initial quota', e);
|
logger.warn('Failed to fetch initial quota', e);
|
||||||
@@ -97,7 +318,12 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listCloudAccounts(): 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> {
|
export async function deleteCloudAccount(accountId: string): Promise<void> {
|
||||||
@@ -110,70 +336,150 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
|||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token needs refresh
|
|
||||||
let now = Math.floor(Date.now() / 1000);
|
let now = Math.floor(Date.now() / 1000);
|
||||||
if (account.token.expiry_timestamp < now + 300) {
|
if (account.token.expiry_timestamp < now + 300) {
|
||||||
// 5 minutes buffer
|
|
||||||
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
logger.info(`Token for ${account.email} near expiry, refreshing...`);
|
||||||
try {
|
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.access_token = newTokenData.access_token;
|
||||||
account.token.expires_in = newTokenData.expires_in;
|
account.token.expires_in = newTokenData.expires_in;
|
||||||
account.token.expiry_timestamp = now + 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);
|
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`Failed to refresh token during time-check for ${account.email}`, 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 {
|
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;
|
account.quota = quota;
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||||
await CloudAccountRepo.updateLastUsed(account.id);
|
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);
|
notifyTrayUpdate(account);
|
||||||
return account;
|
return account;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === 'UNAUTHORIZED') {
|
if (error.message === 'UNAUTHORIZED') {
|
||||||
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
|
||||||
try {
|
try {
|
||||||
// Force Refresh
|
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||||
const newTokenData = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
account.token.refresh_token,
|
||||||
|
account.proxy_url,
|
||||||
|
account.token.oauth_client_key,
|
||||||
|
);
|
||||||
now = Math.floor(Date.now() / 1000);
|
now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
account.token.access_token = newTokenData.access_token;
|
account.token.access_token = newTokenData.access_token;
|
||||||
account.token.expires_in = newTokenData.expires_in;
|
account.token.expires_in = newTokenData.expires_in;
|
||||||
account.token.expiry_timestamp = now + 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);
|
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||||
|
|
||||||
// Retry Quota
|
const previousAICredits = account.quota?.ai_credits;
|
||||||
const quota = await GoogleAPIService.fetchQuota(account.token.access_token);
|
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;
|
account.quota = quota;
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||||
await CloudAccountRepo.updateLastUsed(account.id);
|
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;
|
return account;
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to force refresh token or retry quota for ${account.email}`,
|
`Failed to force refresh token or retry quota for ${account.email}`,
|
||||||
refreshError,
|
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;
|
throw refreshError;
|
||||||
}
|
}
|
||||||
} else if (error.message === 'FORBIDDEN') {
|
} else if (error.message === 'FORBIDDEN') {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
`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;
|
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);
|
logger.error(`Failed to refresh quota for ${account.email}`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -200,32 +506,47 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
|||||||
// 1. Prepare token refresh promise (start it in parallel with process exit)
|
// 1. Prepare token refresh promise (start it in parallel with process exit)
|
||||||
const tokenRefreshPromise = (async () => {
|
const tokenRefreshPromise = (async () => {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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...`);
|
logger.info(`Token for ${account.email} near expiry, refreshing in parallel...`);
|
||||||
try {
|
try {
|
||||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||||
account.token.refresh_token,
|
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;
|
const updatedToken = {
|
||||||
account.token.expiry_timestamp = now + newTokenData.expires_in;
|
...account.token,
|
||||||
await CloudAccountRepo.updateToken(account.id, 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}`);
|
logger.info(`Token refreshed for ${account.email}`);
|
||||||
} catch (e) {
|
} 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({
|
await executeSwitchFlow({
|
||||||
scope: 'cloud',
|
scope: 'cloud',
|
||||||
targetProfile: account.device_profile || null,
|
targetProfile: account.device_profile || null,
|
||||||
applyFingerprint: isIdentityProfileApplyEnabled(),
|
applyFingerprint: isIdentityProfileApplyEnabled(),
|
||||||
processExitTimeoutMs: 10000,
|
processExitTimeoutMs: 10000,
|
||||||
performSwitch: async () => {
|
performSwitch: async () => {
|
||||||
// Wait for token refresh to complete before injection if it was started
|
|
||||||
await tokenRefreshPromise;
|
|
||||||
|
|
||||||
// 3. Backup Database (Optimized to avoid race conditions)
|
// 3. Backup Database (Optimized to avoid race conditions)
|
||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
for (const dbPath of dbPaths) {
|
for (const dbPath of dbPaths) {
|
||||||
@@ -247,6 +568,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
|||||||
// 5. Update usage and active status
|
// 5. Update usage and active status
|
||||||
CloudAccountRepo.updateLastUsed(account.id);
|
CloudAccountRepo.updateLastUsed(account.id);
|
||||||
CloudAccountRepo.setActive(account.id);
|
CloudAccountRepo.setActive(account.id);
|
||||||
|
await clearAccountStatus(account);
|
||||||
|
|
||||||
logger.info(`Successfully switched to cloud account: ${account.email}`);
|
logger.info(`Successfully switched to cloud account: ${account.email}`);
|
||||||
notifyTrayUpdate(account);
|
notifyTrayUpdate(account);
|
||||||
@@ -374,9 +696,148 @@ export async function forcePollCloudMonitor(): Promise<void> {
|
|||||||
await CloudMonitorService.poll();
|
await CloudMonitorService.poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startAuthFlow(): Promise<void> {
|
export async function startAuthFlow(oauthClientKey?: string): 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`;
|
if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) {
|
||||||
|
setActiveOAuthClient(oauthClientKey);
|
||||||
|
} else {
|
||||||
|
hydrateActiveOAuthClientFromSettings();
|
||||||
|
}
|
||||||
|
const url = GoogleAPIService.getAuthUrl(oauthClientKey);
|
||||||
|
|
||||||
logger.info(`Starting auth flow, opening URL: ${url}`);
|
logger.info(`Starting auth flow, opening URL: ${url}`);
|
||||||
await shell.openExternal(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 { z } from 'zod';
|
||||||
import { os } from '@orpc/server';
|
import { os, ORPCError } from '@orpc/server';
|
||||||
import {
|
import {
|
||||||
addGoogleAccount,
|
addGoogleAccount,
|
||||||
bindCloudIdentityProfile,
|
bindCloudIdentityProfile,
|
||||||
@@ -18,10 +18,15 @@ import {
|
|||||||
setAutoSwitchEnabled,
|
setAutoSwitchEnabled,
|
||||||
forcePollCloudMonitor,
|
forcePollCloudMonitor,
|
||||||
startAuthFlow,
|
startAuthFlow,
|
||||||
|
listOAuthClients,
|
||||||
|
getActiveOAuthClient,
|
||||||
|
setActiveOAuthClient,
|
||||||
|
exportCloudAccounts,
|
||||||
|
importCloudAccounts,
|
||||||
} from './handler';
|
} from './handler';
|
||||||
|
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
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({
|
export const cloudRouter = os.router({
|
||||||
addGoogleAccount: os
|
addGoogleAccount: os
|
||||||
.input(z.object({ authCode: z.string() }))
|
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||||
.output(CloudAccountSchema)
|
.output(CloudAccountSchema)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
return addGoogleAccount(input.authCode);
|
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||||
@@ -113,10 +150,54 @@ export const cloudRouter = os.router({
|
|||||||
await forcePollCloudMonitor();
|
await forcePollCloudMonitor();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
startAuthFlow: os.output(z.void()).handler(async () => {
|
startAuthFlow: os
|
||||||
await startAuthFlow();
|
.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 () => {
|
syncLocalAccount: os.output(CloudAccountSchema.nullable()).handler(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await CloudAccountRepo.syncFromIDE();
|
const result = await CloudAccountRepo.syncFromIDE();
|
||||||
@@ -124,7 +205,7 @@ export const cloudRouter = os.router({
|
|||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
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 () => {
|
openIdentityStorageFolder: os.output(z.void()).handler(async () => {
|
||||||
await openCloudIdentityStorageFolder();
|
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 { ConfigManager } from './manager';
|
||||||
import { syncAutoStart } from '../../utils/autoStart';
|
import { syncAutoStart } from '../../utils/autoStart';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import { setServerConfig } from '../../server/server-config';
|
||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
return ConfigManager.loadConfig();
|
return ConfigManager.loadConfig();
|
||||||
@@ -13,6 +14,7 @@ export async function saveConfig(config: AppConfig): Promise<void> {
|
|||||||
// For now just save
|
// For now just save
|
||||||
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
const previous = ConfigManager.getCachedConfig() ?? ConfigManager.loadConfig();
|
||||||
await ConfigManager.saveConfig(config);
|
await ConfigManager.saveConfig(config);
|
||||||
|
setServerConfig(config.proxy);
|
||||||
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
logger.setErrorReportingEnabled(config.error_reporting_enabled);
|
||||||
if (previous.auto_startup !== config.auto_startup) {
|
if (previous.auto_startup !== config.auto_startup) {
|
||||||
syncAutoStart(config);
|
syncAutoStart(config);
|
||||||
|
|||||||
+310
-113
@@ -4,9 +4,15 @@ import fs from 'fs';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { desc, eq } from 'drizzle-orm';
|
import { desc, eq } from 'drizzle-orm';
|
||||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { isNumber, isObjectLike, isPlainObject, isString } from 'lodash-es';
|
||||||
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths';
|
||||||
import { logger } from '../../utils/logger';
|
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 { type DeviceProfile, type DeviceProfileVersion } from '../../types/account';
|
||||||
import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
import { ItemTableValueRowSchema, TableInfoRowSchema } from '../../types/db';
|
||||||
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
import { decryptWithMigration, encrypt, type KeySource } from '../../utils/security';
|
||||||
@@ -30,14 +36,14 @@ type DrizzleExecutor = Pick<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
function isSqliteBusyError(error: unknown): boolean {
|
function isSqliteBusyError(error: unknown): boolean {
|
||||||
if (!error || typeof error !== 'object') {
|
if (!isObjectLike(error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const err = error as { code?: string; message?: string };
|
const err = error as { code?: string; message?: string };
|
||||||
if (err.code && SQLITE_BUSY_CODES.has(err.code)) {
|
if (err.code && SQLITE_BUSY_CODES.has(err.code)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (typeof err.message === 'string') {
|
if (isString(err.message)) {
|
||||||
return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED');
|
return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -80,6 +86,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_used INTEGER NOT NULL,
|
last_used INTEGER NOT NULL,
|
||||||
status TEXT DEFAULT 'active',
|
status TEXT DEFAULT 'active',
|
||||||
|
status_reason TEXT,
|
||||||
is_active INTEGER DEFAULT 0
|
is_active INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
@@ -90,6 +97,8 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
const hasIsActive = tableInfo.some((col) => col.name === 'is_active');
|
||||||
const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
const hasDeviceProfileJson = tableInfo.some((col) => col.name === 'device_profile_json');
|
||||||
const hasDeviceHistoryJson = tableInfo.some((col) => col.name === 'device_history_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) {
|
if (!hasIsActive) {
|
||||||
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
db.exec('ALTER TABLE accounts ADD COLUMN is_active INTEGER DEFAULT 0');
|
||||||
}
|
}
|
||||||
@@ -99,6 +108,12 @@ function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
if (!hasDeviceHistoryJson) {
|
if (!hasDeviceHistoryJson) {
|
||||||
db.exec('ALTER TABLE accounts ADD COLUMN device_history_json TEXT');
|
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
|
||||||
// Create index on email for faster lookups
|
// Create index on email for faster lookups
|
||||||
@@ -154,17 +169,13 @@ interface MigrationStats {
|
|||||||
failedFields: number;
|
failedFields: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStringCandidate(
|
function readStringCandidate(
|
||||||
source: Record<string, unknown>,
|
source: Record<string, unknown>,
|
||||||
...keys: string[]
|
...keys: string[]
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const candidate = source[key];
|
const candidate = source[key];
|
||||||
if (typeof candidate === 'string' && candidate.length > 0) {
|
if (isString(candidate) && candidate.length > 0) {
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,14 +183,15 @@ function readStringCandidate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined {
|
function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined {
|
||||||
if (!isRecord(value)) {
|
if (!isPlainObject(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const valueRecord = value as Record<string, unknown>;
|
||||||
|
|
||||||
const machineId = readStringCandidate(value, 'machineId', 'machine_id');
|
const machineId = readStringCandidate(valueRecord, 'machineId', 'machine_id');
|
||||||
const macMachineId = readStringCandidate(value, 'macMachineId', 'mac_machine_id');
|
const macMachineId = readStringCandidate(valueRecord, 'macMachineId', 'mac_machine_id');
|
||||||
const devDeviceId = readStringCandidate(value, 'devDeviceId', 'dev_device_id');
|
const devDeviceId = readStringCandidate(valueRecord, 'devDeviceId', 'dev_device_id');
|
||||||
const sqmId = readStringCandidate(value, 'sqmId', 'sqm_id');
|
const sqmId = readStringCandidate(valueRecord, 'sqmId', 'sqm_id');
|
||||||
|
|
||||||
if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
|
if (!machineId || !macMachineId || !devDeviceId || !sqmId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -203,45 +215,47 @@ function areDeviceProfilesEqual(left: DeviceProfile, right: DeviceProfile): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readVersionedProfilePayload(value: unknown): unknown {
|
function readVersionedProfilePayload(value: unknown): unknown {
|
||||||
if (!isRecord(value)) {
|
if (!isPlainObject(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (!('schemaVersion' in value)) {
|
const valueRecord = value as Record<string, unknown>;
|
||||||
|
if (!('schemaVersion' in valueRecord)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaVersion = value.schemaVersion;
|
const schemaVersion = valueRecord.schemaVersion;
|
||||||
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
|
||||||
throw new Error('invalid_device_profile_schema_version');
|
throw new Error('invalid_device_profile_schema_version');
|
||||||
}
|
}
|
||||||
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
||||||
throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`);
|
throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`);
|
||||||
}
|
}
|
||||||
if (!('profile' in value)) {
|
if (!('profile' in valueRecord)) {
|
||||||
throw new Error('invalid_device_profile_payload');
|
throw new Error('invalid_device_profile_payload');
|
||||||
}
|
}
|
||||||
return value.profile;
|
return valueRecord.profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readVersionedHistoryPayload(value: unknown): unknown {
|
function readVersionedHistoryPayload(value: unknown): unknown {
|
||||||
if (!isRecord(value)) {
|
if (!isPlainObject(value)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (!('schemaVersion' in value)) {
|
const valueRecord = value as Record<string, unknown>;
|
||||||
|
if (!('schemaVersion' in valueRecord)) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaVersion = value.schemaVersion;
|
const schemaVersion = valueRecord.schemaVersion;
|
||||||
if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) {
|
if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) {
|
||||||
throw new Error('invalid_device_history_schema_version');
|
throw new Error('invalid_device_history_schema_version');
|
||||||
}
|
}
|
||||||
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) {
|
||||||
throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`);
|
throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`);
|
||||||
}
|
}
|
||||||
if (!('history' in value)) {
|
if (!('history' in valueRecord)) {
|
||||||
throw new Error('invalid_device_history_payload');
|
throw new Error('invalid_device_history_payload');
|
||||||
}
|
}
|
||||||
return value.history;
|
return valueRecord.history;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null {
|
function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null {
|
||||||
@@ -271,23 +285,25 @@ function normalizeDeviceHistory(value: unknown): DeviceProfileVersion[] | undefi
|
|||||||
|
|
||||||
const normalized: DeviceProfileVersion[] = [];
|
const normalized: DeviceProfileVersion[] = [];
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
if (!isRecord(item)) {
|
if (!isPlainObject(item)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const itemRecord = item as Record<string, unknown>;
|
||||||
|
|
||||||
const profile = normalizeDeviceProfile(item.profile);
|
const profile = normalizeDeviceProfile(itemRecord.profile);
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : uuidv4();
|
const id = isString(itemRecord.id) && itemRecord.id.length > 0 ? itemRecord.id : uuidv4();
|
||||||
const createdAtCandidate = item.createdAt;
|
const createdAtCandidate = itemRecord.createdAt;
|
||||||
const createdAt =
|
const createdAt =
|
||||||
typeof createdAtCandidate === 'number' && Number.isFinite(createdAtCandidate)
|
isNumber(createdAtCandidate) && Number.isFinite(createdAtCandidate)
|
||||||
? Math.floor(createdAtCandidate)
|
? Math.floor(createdAtCandidate)
|
||||||
: Math.floor(Date.now() / 1000);
|
: Math.floor(Date.now() / 1000);
|
||||||
const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'legacy';
|
const label =
|
||||||
const isCurrent = item.isCurrent === true;
|
isString(itemRecord.label) && itemRecord.label.length > 0 ? itemRecord.label : 'legacy';
|
||||||
|
const isCurrent = itemRecord.isCurrent === true;
|
||||||
|
|
||||||
normalized.push({
|
normalized.push({
|
||||||
id,
|
id,
|
||||||
@@ -450,6 +466,9 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async addAccount(account: CloudAccount): Promise<void> {
|
static async addAccount(account: CloudAccount): Promise<void> {
|
||||||
|
// Validate account data before processing
|
||||||
|
CloudAccountSchema.parse(account);
|
||||||
|
|
||||||
const { raw, orm } = getCloudDb();
|
const { raw, orm } = getCloudDb();
|
||||||
try {
|
try {
|
||||||
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
const tokenEncrypted = await encrypt(JSON.stringify(account.token));
|
||||||
@@ -467,7 +486,9 @@ export class CloudAccountRepo {
|
|||||||
createdAt: account.created_at,
|
createdAt: account.created_at,
|
||||||
lastUsed: account.last_used,
|
lastUsed: account.last_used,
|
||||||
status: account.status || 'active',
|
status: account.status || 'active',
|
||||||
|
statusReason: account.status_reason ?? null,
|
||||||
isActive: account.is_active ? 1 : 0,
|
isActive: account.is_active ? 1 : 0,
|
||||||
|
proxyUrl: account.proxy_url ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
orm.transaction((tx) => {
|
orm.transaction((tx) => {
|
||||||
@@ -509,79 +530,87 @@ export class CloudAccountRepo {
|
|||||||
|
|
||||||
const cloudAccounts: CloudAccount[] = [];
|
const cloudAccounts: CloudAccount[] = [];
|
||||||
for (const normalizedRow of rows) {
|
for (const normalizedRow of rows) {
|
||||||
let tokenResult: DecryptFieldResult;
|
|
||||||
try {
|
try {
|
||||||
tokenResult = await decryptAndMigrateField(
|
let tokenResult: DecryptFieldResult;
|
||||||
orm,
|
try {
|
||||||
normalizedRow.id,
|
tokenResult = await decryptAndMigrateField(
|
||||||
'tokenJson',
|
orm,
|
||||||
normalizedRow.tokenJson,
|
normalizedRow.id,
|
||||||
);
|
'tokenJson',
|
||||||
} catch (error) {
|
normalizedRow.tokenJson,
|
||||||
migrationStats.failedFields += 1;
|
);
|
||||||
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
} catch (error) {
|
||||||
throw error;
|
migrationStats.failedFields += 1;
|
||||||
}
|
logger.error(`Failed to decrypt token for account ${normalizedRow.id}`, error);
|
||||||
|
continue; // Skip corrupted account
|
||||||
|
}
|
||||||
|
|
||||||
let quotaResult: DecryptFieldResult;
|
let quotaResult: DecryptFieldResult;
|
||||||
try {
|
try {
|
||||||
quotaResult = await decryptAndMigrateField(
|
quotaResult = await decryptAndMigrateField(
|
||||||
orm,
|
orm,
|
||||||
normalizedRow.id,
|
normalizedRow.id,
|
||||||
'quotaJson',
|
'quotaJson',
|
||||||
normalizedRow.quotaJson,
|
normalizedRow.quotaJson,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
migrationStats.failedFields += 1;
|
migrationStats.failedFields += 1;
|
||||||
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
logger.error(`Failed to decrypt quota for account ${normalizedRow.id}`, error);
|
||||||
throw error;
|
quotaResult = { value: null, migrated: false }; // Quota is optional, proceed
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tokenResult.value) {
|
if (!tokenResult.value) {
|
||||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
logger.warn(`Missing token data for account ${normalizedRow.id}`);
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (tokenResult.value) {
|
if (tokenResult.value) {
|
||||||
migrationStats.totalFields += 1;
|
migrationStats.totalFields += 1;
|
||||||
}
|
}
|
||||||
if (tokenResult.usedFallback) {
|
|
||||||
migrationStats.fallbackUsedFields += 1;
|
|
||||||
}
|
|
||||||
if (tokenResult.migrated) {
|
|
||||||
migrationStats.migratedFields += 1;
|
|
||||||
if (tokenResult.usedFallback) {
|
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) {
|
if (quotaResult.value) {
|
||||||
migrationStats.totalFields += 1;
|
migrationStats.totalFields += 1;
|
||||||
}
|
}
|
||||||
if (quotaResult.usedFallback) {
|
|
||||||
migrationStats.fallbackUsedFields += 1;
|
|
||||||
}
|
|
||||||
if (quotaResult.migrated) {
|
|
||||||
migrationStats.migratedFields += 1;
|
|
||||||
if (quotaResult.usedFallback) {
|
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({
|
cloudAccounts.push({
|
||||||
id: normalizedRow.id,
|
id: normalizedRow.id,
|
||||||
provider: normalizedRow.provider as CloudAccount['provider'],
|
provider: normalizedRow.provider as CloudAccount['provider'],
|
||||||
email: normalizedRow.email,
|
email: normalizedRow.email,
|
||||||
name: normalizedRow.name ?? undefined,
|
name: normalizedRow.name ?? undefined,
|
||||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||||
token: JSON.parse(tokenResult.value),
|
token: JSON.parse(tokenResult.value),
|
||||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||||
created_at: normalizedRow.createdAt,
|
created_at: normalizedRow.createdAt,
|
||||||
last_used: normalizedRow.lastUsed,
|
last_used: normalizedRow.lastUsed,
|
||||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||||
is_active: Boolean(normalizedRow.isActive),
|
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;
|
return cloudAccounts;
|
||||||
@@ -618,21 +647,41 @@ export class CloudAccountRepo {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenResult = await decryptAndMigrateField(
|
let tokenResult: DecryptFieldResult;
|
||||||
orm,
|
try {
|
||||||
normalizedRow.id,
|
tokenResult = await decryptAndMigrateField(
|
||||||
'tokenJson',
|
orm,
|
||||||
normalizedRow.tokenJson,
|
normalizedRow.id,
|
||||||
);
|
'tokenJson',
|
||||||
const quotaResult = await decryptAndMigrateField(
|
normalizedRow.tokenJson,
|
||||||
orm,
|
);
|
||||||
normalizedRow.id,
|
} catch (error) {
|
||||||
'quotaJson',
|
logger.error(
|
||||||
normalizedRow.quotaJson,
|
`[CloudAccountRepo] getAccount ${id} failed - Decryption failed for token`,
|
||||||
);
|
error,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
if (!tokenResult.value) {
|
let quotaResult: DecryptFieldResult;
|
||||||
throw new Error(`Missing token data for account ${normalizedRow.id}`);
|
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 {
|
return {
|
||||||
@@ -641,14 +690,16 @@ export class CloudAccountRepo {
|
|||||||
email: normalizedRow.email,
|
email: normalizedRow.email,
|
||||||
name: normalizedRow.name ?? undefined,
|
name: normalizedRow.name ?? undefined,
|
||||||
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
avatar_url: normalizedRow.avatarUrl ?? undefined,
|
||||||
token: JSON.parse(tokenResult.value),
|
token: JSON.parse(tokenValue),
|
||||||
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
quota: quotaResult.value ? JSON.parse(quotaResult.value) : undefined,
|
||||||
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
device_profile: parseDeviceProfileColumn(normalizedRow.deviceProfileJson),
|
||||||
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
device_history: parseDeviceHistoryColumn(normalizedRow.deviceHistoryJson),
|
||||||
created_at: normalizedRow.createdAt,
|
created_at: normalizedRow.createdAt,
|
||||||
last_used: normalizedRow.lastUsed,
|
last_used: normalizedRow.lastUsed,
|
||||||
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
status: (normalizedRow.status as CloudAccount['status']) ?? undefined,
|
||||||
|
status_reason: normalizedRow.statusReason ?? undefined,
|
||||||
is_active: Boolean(normalizedRow.isActive),
|
is_active: Boolean(normalizedRow.isActive),
|
||||||
|
proxy_url: normalizedRow.proxyUrl ?? undefined,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
raw.close();
|
raw.close();
|
||||||
@@ -666,22 +717,42 @@ export class CloudAccountRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async updateToken(id: string, token: any): Promise<void> {
|
static async updateToken(id: string, token: any): Promise<void> {
|
||||||
|
// Validate token data before encryption
|
||||||
|
CloudTokenDataSchema.parse(token);
|
||||||
|
|
||||||
const { raw, orm } = getCloudDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = await encrypt(JSON.stringify(token));
|
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 {
|
} finally {
|
||||||
raw.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async updateQuota(id: string, quota: any): Promise<void> {
|
static async updateQuota(id: string, quota: any): Promise<void> {
|
||||||
|
// Validate quota data before encryption
|
||||||
|
CloudQuotaDataSchema.parse(quota);
|
||||||
|
|
||||||
const { raw, orm } = getCloudDb();
|
const { raw, orm } = getCloudDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const encrypted = await encrypt(JSON.stringify(quota));
|
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 {
|
} finally {
|
||||||
raw.close();
|
raw.close();
|
||||||
}
|
}
|
||||||
@@ -911,6 +982,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 {
|
private static upsertItemValue(db: DrizzleExecutor, key: string, value: string): void {
|
||||||
db.insert(itemTable)
|
db.insert(itemTable)
|
||||||
.values({ key, value })
|
.values({ key, value })
|
||||||
@@ -951,10 +1060,35 @@ export class CloudAccountRepo {
|
|||||||
account.token.access_token,
|
account.token.access_token,
|
||||||
account.token.refresh_token,
|
account.token.refresh_token,
|
||||||
account.token.expiry_timestamp,
|
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) => {
|
orm.transaction((tx) => {
|
||||||
this.upsertItemValue(tx, 'antigravityUnifiedStateSync.oauthToken', oauthToken);
|
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);
|
this.writeAuthStatusAndCleanup(tx, account);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1173,7 +1307,9 @@ export class CloudAccountRepo {
|
|||||||
private static readTokenInfoFromDb(db: DrizzleExecutor): {
|
private static readTokenInfoFromDb(db: DrizzleExecutor): {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
projectId?: string;
|
||||||
} {
|
} {
|
||||||
|
const enterpriseProjectId = this.readEnterpriseProjectIdFromDb(db);
|
||||||
const unifiedValue = this.getItemValue(
|
const unifiedValue = this.getItemValue(
|
||||||
db,
|
db,
|
||||||
'antigravityUnifiedStateSync.oauthToken',
|
'antigravityUnifiedStateSync.oauthToken',
|
||||||
@@ -1217,12 +1353,16 @@ export class CloudAccountRepo {
|
|||||||
throw new Error(errorMsg);
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokenInfo;
|
return {
|
||||||
|
...tokenInfo,
|
||||||
|
projectId: enterpriseProjectId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readTokenInfoWithRetry(dbPath: string): {
|
private static readTokenInfoWithRetry(dbPath: string): {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
projectId?: string;
|
||||||
} {
|
} {
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) {
|
for (let attempt = 1; attempt <= SQLITE_MAX_RETRIES; attempt += 1) {
|
||||||
@@ -1244,6 +1384,41 @@ export class CloudAccountRepo {
|
|||||||
throw lastError;
|
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> {
|
static async syncFromIDE(): Promise<CloudAccount | null> {
|
||||||
// Try all possible database paths
|
// Try all possible database paths
|
||||||
const dbPaths = getAntigravityDbPaths();
|
const dbPaths = getAntigravityDbPaths();
|
||||||
@@ -1293,6 +1468,8 @@ export class CloudAccountRepo {
|
|||||||
expiry_timestamp: now + 3600,
|
expiry_timestamp: now + 3600,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
|
project_id: tokenInfo.projectId,
|
||||||
|
is_gcp_tos: true,
|
||||||
},
|
},
|
||||||
created_at: now,
|
created_at: now,
|
||||||
last_used: now,
|
last_used: now,
|
||||||
@@ -1304,8 +1481,28 @@ export class CloudAccountRepo {
|
|||||||
const accounts = await this.getAccounts();
|
const accounts = await this.getAccounts();
|
||||||
const existing = accounts.find((a) => a.email === account.email);
|
const existing = accounts.find((a) => a.email === account.email);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
const existingProjectId = existing.token.project_id?.trim();
|
||||||
|
|
||||||
account.id = existing.id; // Keep existing ID
|
account.id = existing.id; // Keep existing ID
|
||||||
account.created_at = existing.created_at;
|
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);
|
await this.addAccount(account);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { isString } from 'lodash-es';
|
||||||
import { AccountBackupData, AccountInfo } from '../../types/account';
|
import { AccountBackupData, AccountInfo } from '../../types/account';
|
||||||
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
@@ -192,8 +193,8 @@ export function getCurrentAccountInfo(): AccountInfo {
|
|||||||
// Helper to find email in object
|
// Helper to find email in object
|
||||||
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
const findEmail = (obj: { email?: string; user?: { email?: string } }): string => {
|
||||||
if (!obj) return '';
|
if (!obj) return '';
|
||||||
if (typeof obj.email === 'string') return obj.email;
|
if (isString(obj.email)) return obj.email;
|
||||||
if (obj.user && typeof obj.user.email === 'string') return obj.user.email;
|
if (obj.user && isString(obj.user.email)) return obj.user.email;
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -323,7 +324,7 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean {
|
|||||||
for (const key of KEYS_TO_BACKUP) {
|
for (const key of KEYS_TO_BACKUP) {
|
||||||
if (key in backup.data) {
|
if (key in backup.data) {
|
||||||
const value = backup.data[key];
|
const value = backup.data[key];
|
||||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
const stringValue = isString(value) ? value : JSON.stringify(value);
|
||||||
tx.insert(itemTable)
|
tx.insert(itemTable)
|
||||||
.values({ key, value: stringValue })
|
.values({ key, value: stringValue })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export const accounts = sqliteTable('accounts', {
|
|||||||
deviceHistoryJson: text('device_history_json'),
|
deviceHistoryJson: text('device_history_json'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
lastUsed: integer('last_used').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),
|
isActive: integer('is_active').notNull().default(0),
|
||||||
|
proxyUrl: text('proxy_url'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settings = sqliteTable('settings', {
|
export const settings = sqliteTable('settings', {
|
||||||
|
|||||||
+11
-10
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { createHash, randomBytes } from 'crypto';
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import { isBoolean, isNumber, isObjectLike, isString } from 'lodash-es';
|
||||||
import { type DeviceProfile } from '../../types/account';
|
import { type DeviceProfile } from '../../types/account';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getAgentDir, getAntigravityDbPaths, getAntigravityStoragePaths } from '../../utils/paths';
|
import { getAgentDir, getAntigravityDbPaths, getAntigravityStoragePaths } from '../../utils/paths';
|
||||||
@@ -76,14 +77,14 @@ const deviceHardeningState: DeviceHardeningState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isSqliteBusyError(error: unknown): boolean {
|
function isSqliteBusyError(error: unknown): boolean {
|
||||||
if (!error || typeof error !== 'object') {
|
if (!isObjectLike(error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const candidate = error as { code?: string; message?: string };
|
const candidate = error as { code?: string; message?: string };
|
||||||
if (candidate.code === 'SQLITE_BUSY' || candidate.code === 'SQLITE_LOCKED') {
|
if (candidate.code === 'SQLITE_BUSY' || candidate.code === 'SQLITE_LOCKED') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (typeof candidate.message === 'string') {
|
if (isString(candidate.message)) {
|
||||||
return candidate.message.includes('SQLITE_BUSY') || candidate.message.includes('SQLITE_LOCKED');
|
return candidate.message.includes('SQLITE_BUSY') || candidate.message.includes('SQLITE_LOCKED');
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -139,8 +140,8 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null {
|
|||||||
if (
|
if (
|
||||||
parsed &&
|
parsed &&
|
||||||
parsed.version === 1 &&
|
parsed.version === 1 &&
|
||||||
typeof parsed.savedAt === 'number' &&
|
isNumber(parsed.savedAt) &&
|
||||||
typeof parsed.hasStateDb === 'boolean'
|
isBoolean(parsed.hasStateDb)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -157,7 +158,7 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null {
|
|||||||
function parseStorageJson(storagePath: string): Record<string, unknown> {
|
function parseStorageJson(storagePath: string): Record<string, unknown> {
|
||||||
const content = fs.readFileSync(storagePath, 'utf-8');
|
const content = fs.readFileSync(storagePath, 'utf-8');
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
if (!isObjectLike(parsed) || Array.isArray(parsed)) {
|
||||||
throw new Error('storage.json top-level value must be an object');
|
throw new Error('storage.json top-level value must be an object');
|
||||||
}
|
}
|
||||||
return parsed as Record<string, unknown>;
|
return parsed as Record<string, unknown>;
|
||||||
@@ -165,15 +166,15 @@ function parseStorageJson(storagePath: string): Record<string, unknown> {
|
|||||||
|
|
||||||
function getTelemetryField(storage: Record<string, unknown>, key: string): string | null {
|
function getTelemetryField(storage: Record<string, unknown>, key: string): string | null {
|
||||||
const telemetry = storage.telemetry;
|
const telemetry = storage.telemetry;
|
||||||
if (telemetry && typeof telemetry === 'object' && !Array.isArray(telemetry)) {
|
if (isObjectLike(telemetry) && !Array.isArray(telemetry)) {
|
||||||
const nestedValue = (telemetry as Record<string, unknown>)[key];
|
const nestedValue = (telemetry as Record<string, unknown>)[key];
|
||||||
if (typeof nestedValue === 'string' && nestedValue.length > 0) {
|
if (isString(nestedValue) && nestedValue.length > 0) {
|
||||||
return nestedValue;
|
return nestedValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const flatValue = storage[`telemetry.${key}`];
|
const flatValue = storage[`telemetry.${key}`];
|
||||||
if (typeof flatValue === 'string' && flatValue.length > 0) {
|
if (isString(flatValue) && flatValue.length > 0) {
|
||||||
return flatValue;
|
return flatValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@ function getTelemetryField(storage: Record<string, unknown>, key: string): strin
|
|||||||
function ensureTelemetryObject(storage: Record<string, unknown>): Record<string, unknown> {
|
function ensureTelemetryObject(storage: Record<string, unknown>): Record<string, unknown> {
|
||||||
if (
|
if (
|
||||||
!storage.telemetry ||
|
!storage.telemetry ||
|
||||||
typeof storage.telemetry !== 'object' ||
|
!isObjectLike(storage.telemetry) ||
|
||||||
Array.isArray(storage.telemetry)
|
Array.isArray(storage.telemetry)
|
||||||
) {
|
) {
|
||||||
storage.telemetry = {};
|
storage.telemetry = {};
|
||||||
@@ -223,7 +224,7 @@ function readStateServiceMachineIdValue(dbPath: string): string | null {
|
|||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT value FROM ItemTable WHERE key = 'storage.serviceMachineId' LIMIT 1")
|
.prepare("SELECT value FROM ItemTable WHERE key = 'storage.serviceMachineId' LIMIT 1")
|
||||||
.get() as { value?: unknown } | undefined;
|
.get() as { value?: unknown } | undefined;
|
||||||
if (!row || typeof row.value !== 'string' || row.value.length === 0) {
|
if (!row || !isString(row.value) || row.value.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return row.value;
|
return row.value;
|
||||||
|
|||||||
+11
-105
@@ -1,114 +1,16 @@
|
|||||||
import { IPC_CHANNELS } from '@/constants';
|
import { IPC_CHANNELS } from '@/constants';
|
||||||
|
import { createORPCClient } from '@orpc/client';
|
||||||
|
import { RPCLink } from '@orpc/client/message-port';
|
||||||
|
import type { RouterClient } from '@orpc/server';
|
||||||
|
import type { router } from './router';
|
||||||
|
|
||||||
// Custom IPC Client that correctly builds method paths
|
type IPCClient = RouterClient<typeof router>;
|
||||||
function createIPCClient(port: MessagePort) {
|
|
||||||
let requestId = 0;
|
|
||||||
const pendingRequests = new Map<
|
|
||||||
string,
|
|
||||||
{ resolve: (v: any) => void; reject: (e: any) => void }
|
|
||||||
>();
|
|
||||||
|
|
||||||
port.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
||||||
|
|
||||||
const id = data.i || data.id;
|
|
||||||
const pending = pendingRequests.get(id);
|
|
||||||
if (pending) {
|
|
||||||
pendingRequests.delete(id);
|
|
||||||
if (data.e || data.error) {
|
|
||||||
const errorMsg = data.e?.m || data.error?.message || JSON.stringify(data.e || data.error);
|
|
||||||
console.error('[IPCClient] Error response:', errorMsg);
|
|
||||||
pending.reject(new Error(errorMsg));
|
|
||||||
} else {
|
|
||||||
// ORPC response format: { i, p: { b: { json: value }, s?: statusCode } }
|
|
||||||
// Or error: { i, p: { s: 500, b: { json: { code, message } } } }
|
|
||||||
let result;
|
|
||||||
const payload = data.p;
|
|
||||||
|
|
||||||
// Check for error status
|
|
||||||
if (payload?.s && payload.s >= 400) {
|
|
||||||
const errorData = payload.b?.json;
|
|
||||||
const errorMsg = errorData?.message || errorData?.code || 'Server error';
|
|
||||||
console.error('[IPCClient] Server error:', errorData);
|
|
||||||
pending.reject(new Error(errorMsg));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract successful result
|
|
||||||
if (payload?.b?.json !== undefined) {
|
|
||||||
result = payload.b.json;
|
|
||||||
} else if (data.r !== undefined) {
|
|
||||||
// Fallback to 'r' field if present
|
|
||||||
result = data.r?.b?.json ?? data.r;
|
|
||||||
} else if ('result' in data) {
|
|
||||||
result = data.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
pending.resolve(result);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('[IPCClient] No pending request for id:', id);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[IPCClient] Error parsing response:', e, event.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function callMethod(path: string[], input?: unknown): Promise<any> {
|
|
||||||
const id = String(++requestId);
|
|
||||||
const methodPath = path.join('/');
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
pendingRequests.set(id, { resolve, reject });
|
|
||||||
|
|
||||||
// ORPC MessagePort protocol format
|
|
||||||
const payload = {
|
|
||||||
i: id,
|
|
||||||
p: {
|
|
||||||
u: `orpc://localhost/${methodPath}`,
|
|
||||||
b: { json: input ?? null },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
port.postMessage(JSON.stringify(payload));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (pendingRequests.has(id)) {
|
|
||||||
pendingRequests.delete(id);
|
|
||||||
reject(new Error(`Request /${methodPath} timed out`));
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Proxy that builds the path chain
|
|
||||||
function createProxyChain(pathSoFar: string[] = []): any {
|
|
||||||
return new Proxy(() => {}, {
|
|
||||||
get(_target, prop: string) {
|
|
||||||
if (prop === 'then' || prop === 'catch' || prop === 'finally') {
|
|
||||||
// Don't intercept promise methods
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Build deeper path
|
|
||||||
return createProxyChain([...pathSoFar, prop]);
|
|
||||||
},
|
|
||||||
apply(_target, _thisArg, args) {
|
|
||||||
// Called as function - execute RPC
|
|
||||||
const input = args[0];
|
|
||||||
return callMethod(pathSoFar, input);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return createProxyChain();
|
|
||||||
}
|
|
||||||
|
|
||||||
class IPCManager {
|
class IPCManager {
|
||||||
private readonly clientPort: MessagePort;
|
private readonly clientPort: MessagePort;
|
||||||
private readonly serverPort: MessagePort;
|
private readonly serverPort: MessagePort;
|
||||||
|
|
||||||
public readonly client: any; // Will be typed via usage
|
public readonly client: IPCClient;
|
||||||
|
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
||||||
@@ -117,7 +19,11 @@ class IPCManager {
|
|||||||
this.clientPort = port1;
|
this.clientPort = port1;
|
||||||
this.serverPort = port2;
|
this.serverPort = port2;
|
||||||
|
|
||||||
this.client = createIPCClient(this.clientPort);
|
this.client = createORPCClient<IPCClient>(
|
||||||
|
new RPCLink({
|
||||||
|
port: this.clientPort,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize() {
|
public initialize() {
|
||||||
|
|||||||
+129
-28
@@ -1,10 +1,14 @@
|
|||||||
import { exec, execSync } from 'child_process';
|
import { exec, execSync, spawn } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import findProcess, { ProcessInfo } from 'find-process';
|
import findProcess, { ProcessInfo } from 'find-process';
|
||||||
|
import { isNumber } from 'lodash-es';
|
||||||
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
import { getAntigravityExecutablePath, isWsl } from '../../utils/paths';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
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)
|
* Helper process name patterns to exclude (Electron helper processes)
|
||||||
@@ -73,19 +77,17 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
const currentPid = process.pid;
|
const currentPid = process.pid;
|
||||||
|
|
||||||
// Use find-process to search for Antigravity processes
|
// Use find-process to search for Antigravity processes
|
||||||
// 'name' search type matches process name
|
const allMatches: ProcessInfo[] = [];
|
||||||
const processMap = new Map<number, ProcessInfo>();
|
|
||||||
const searchNames = ['Antigravity', 'antigravity'];
|
const searchNames = ['Antigravity', 'antigravity'];
|
||||||
|
if (platform === 'linux') {
|
||||||
|
searchNames.push('electron');
|
||||||
|
}
|
||||||
let sawNoMatch = false;
|
let sawNoMatch = false;
|
||||||
|
|
||||||
for (const searchName of searchNames) {
|
for (const searchName of searchNames) {
|
||||||
try {
|
try {
|
||||||
const matches = await findProcess('name', searchName, true);
|
const matches = await findProcess('name', searchName, false);
|
||||||
for (const proc of matches) {
|
allMatches.push(...matches);
|
||||||
if (typeof proc.pid === 'number') {
|
|
||||||
processMap.set(proc.pid, proc);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isPgrepNoMatchError(error)) {
|
if (isPgrepNoMatchError(error)) {
|
||||||
sawNoMatch = true;
|
sawNoMatch = true;
|
||||||
@@ -95,6 +97,13 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const processMap = new Map<number, ProcessInfo>();
|
||||||
|
for (const proc of allMatches) {
|
||||||
|
if (isNumber(proc.pid)) {
|
||||||
|
processMap.set(proc.pid, proc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const processes = Array.from(processMap.values());
|
const processes = Array.from(processMap.values());
|
||||||
if (processes.length === 0 && sawNoMatch) {
|
if (processes.length === 0 && sawNoMatch) {
|
||||||
logger.debug('No Antigravity process found (pgrep returned 1)');
|
logger.debug('No Antigravity process found (pgrep returned 1)');
|
||||||
@@ -145,7 +154,27 @@ export async function isProcessRunning(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else {
|
} 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 (
|
if (
|
||||||
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
(name.includes('antigravity') || cmd.includes('/antigravity')) &&
|
||||||
!name.includes('tools')
|
!name.includes('tools')
|
||||||
@@ -389,6 +418,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.
|
* Starts the Antigravity process.
|
||||||
* @param useUri {boolean} Whether to use the URI protocol to start Antigravity.
|
* @param useUri {boolean} Whether to use the URI protocol to start Antigravity.
|
||||||
@@ -408,7 +504,19 @@ export async function startAntigravity(useUri = true): Promise<void> {
|
|||||||
|
|
||||||
if (await openUri(uri)) {
|
if (await openUri(uri)) {
|
||||||
logger.info('Antigravity URI launch command sent');
|
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 {
|
} else {
|
||||||
logger.warn('URI launch failed, trying executable path...');
|
logger.warn('URI launch failed, trying executable path...');
|
||||||
}
|
}
|
||||||
@@ -419,24 +527,17 @@ export async function startAntigravity(useUri = true): Promise<void> {
|
|||||||
const execPath = getAntigravityExecutablePath();
|
const execPath = getAntigravityExecutablePath();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'darwin') {
|
await startAntigravityByExecutable(execPath);
|
||||||
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();
|
|
||||||
}
|
|
||||||
logger.info('Antigravity launch command sent');
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to start Antigravity via executable', error);
|
logger.error('Failed to start Antigravity via executable', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
+33
-3
@@ -25,18 +25,48 @@ const en: TrayTexts = {
|
|||||||
const zh: TrayTexts = {
|
const zh: TrayTexts = {
|
||||||
current: '当前账号',
|
current: '当前账号',
|
||||||
quota: '当前额度',
|
quota: '当前额度',
|
||||||
switch_next: '切换下一个账号',
|
switch_next: '切换到下一个账号',
|
||||||
refresh_current: '刷新当前额度',
|
refresh_current: '刷新当前额度',
|
||||||
show_window: '显示主窗口',
|
show_window: '显示主窗口',
|
||||||
quit: '退出程序',
|
quit: '退出应用',
|
||||||
no_account: '无账号',
|
no_account: '无账号',
|
||||||
unknown_quota: '未知',
|
unknown_quota: '未知',
|
||||||
forbidden: '账号无权限',
|
forbidden: '账号已被禁用',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ru: TrayTexts = {
|
||||||
|
current: 'Текущий',
|
||||||
|
quota: 'Квота',
|
||||||
|
switch_next: 'Переключить на следующий аккаунт',
|
||||||
|
refresh_current: 'Обновить текущую квоту',
|
||||||
|
show_window: 'Показать главное окно',
|
||||||
|
quit: 'Выйти из приложения',
|
||||||
|
no_account: 'Нет аккаунта',
|
||||||
|
unknown_quota: 'Неизвестно',
|
||||||
|
forbidden: 'Аккаунт заблокирован',
|
||||||
|
};
|
||||||
|
|
||||||
|
const vi: TrayTexts = {
|
||||||
|
current: 'Hiện tại',
|
||||||
|
quota: 'Quota',
|
||||||
|
switch_next: 'Chuyển sang tài khoản tiếp theo',
|
||||||
|
refresh_current: 'Làm mới quota hiện tại',
|
||||||
|
show_window: 'Hiện cửa sổ chính',
|
||||||
|
quit: 'Thoát ứng dụng',
|
||||||
|
no_account: 'Không có tài khoản',
|
||||||
|
unknown_quota: 'Không rõ',
|
||||||
|
forbidden: 'Tài khoản bị cấm',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTrayTexts(lang: string = 'en'): TrayTexts {
|
export function getTrayTexts(lang: string = 'en'): TrayTexts {
|
||||||
if (lang.startsWith('zh')) {
|
if (lang.startsWith('zh')) {
|
||||||
return zh;
|
return zh;
|
||||||
}
|
}
|
||||||
|
if (lang.startsWith('ru')) {
|
||||||
|
return ru;
|
||||||
|
}
|
||||||
|
if (lang.startsWith('vi')) {
|
||||||
|
return vi;
|
||||||
|
}
|
||||||
return en;
|
return en;
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ export const MainLayout: React.FC = () => {
|
|||||||
{
|
{
|
||||||
to: '/proxy',
|
to: '/proxy',
|
||||||
icon: Network,
|
icon: Network,
|
||||||
label: t('nav.proxy', 'API Proxy'),
|
label: t('nav.proxy'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings',
|
to: '/settings',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { mapClaudeModelToGemini } from './ModelMapping';
|
import { isEmpty, isPlainObject, isString } from 'lodash-es';
|
||||||
import { cleanJsonSchema } from './JsonSchemaUtils';
|
import { mapClaudeModelToGemini, normalizeGeminiModelAlias } from './ModelMapping';
|
||||||
|
import { getMaxOutputTokens, getThinkingBudget } from './ModelSpecs';
|
||||||
|
import { cleanJsonSchema, normalizeObjectJsonSchema } from './JsonSchemaUtils';
|
||||||
import { SignatureStore } from './SignatureStore';
|
import { SignatureStore } from './SignatureStore';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +17,11 @@ import {
|
|||||||
FunctionDeclaration,
|
FunctionDeclaration,
|
||||||
SafetySetting,
|
SafetySetting,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import {
|
||||||
|
buildUserAgent,
|
||||||
|
FALLBACK_VERSION,
|
||||||
|
resolveLocalInstalledVersion,
|
||||||
|
} from '@/server/modules/proxy/request-user-agent';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request Configuration
|
* Request Configuration
|
||||||
@@ -44,6 +51,7 @@ interface RequestConfig {
|
|||||||
export function transformClaudeRequestIn(
|
export function transformClaudeRequestIn(
|
||||||
claudeReq: ClaudeRequest,
|
claudeReq: ClaudeRequest,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
|
userAgent?: string,
|
||||||
): GeminiInternalRequest {
|
): GeminiInternalRequest {
|
||||||
// Check for networking tools (server tool or built-in tool)
|
// Check for networking tools (server tool or built-in tool)
|
||||||
const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools);
|
const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools);
|
||||||
@@ -55,9 +63,7 @@ export function transformClaudeRequestIn(
|
|||||||
const systemInstruction = buildSystemInstruction(claudeReq.system);
|
const systemInstruction = buildSystemInstruction(claudeReq.system);
|
||||||
|
|
||||||
// Map model name
|
// Map model name
|
||||||
const mappedModel = hasWebSearchTool
|
const mappedModel = hasWebSearchTool ? 'gemini-3-flash' : mapClaudeModelToGemini(claudeReq.model);
|
||||||
? 'gemini-2.5-flash'
|
|
||||||
: mapClaudeModelToGemini(claudeReq.model);
|
|
||||||
|
|
||||||
// Convert Claude tools to Tool array for networking detection
|
// Convert Claude tools to Tool array for networking detection
|
||||||
const toolsVal: Tool[] | undefined = claudeReq.tools
|
const toolsVal: Tool[] | undefined = claudeReq.tools
|
||||||
@@ -70,7 +76,11 @@ export function transformClaudeRequestIn(
|
|||||||
const allowDummyThought = config.finalModel.startsWith('gemini-');
|
const allowDummyThought = config.finalModel.startsWith('gemini-');
|
||||||
|
|
||||||
// 4. Generation Config & Thinking
|
// 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) {
|
if (isThinkingEnabled) {
|
||||||
const globalSig = SignatureStore.get();
|
const globalSig = SignatureStore.get();
|
||||||
@@ -82,11 +92,18 @@ export function transformClaudeRequestIn(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (hasFunctionCalls && !hasValidSignatureForFunctionCalls(claudeReq.messages, globalSig)) {
|
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
|
// Update thinking config based on the final decision
|
||||||
if (!isThinkingEnabled && generationConfig.thinkingConfig) {
|
if (!isThinkingEnabled && generationConfig.thinkingConfig) {
|
||||||
delete generationConfig.thinkingConfig;
|
delete generationConfig.thinkingConfig;
|
||||||
@@ -98,10 +115,11 @@ export function transformClaudeRequestIn(
|
|||||||
toolIdToName,
|
toolIdToName,
|
||||||
isThinkingEnabled,
|
isThinkingEnabled,
|
||||||
allowDummyThought,
|
allowDummyThought,
|
||||||
|
config.finalModel,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Tools
|
// 3. Tools
|
||||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool);
|
const tools = buildTools(claudeReq.tools, hasWebSearchTool, config.finalModel);
|
||||||
|
|
||||||
// 5. Safety Settings
|
// 5. Safety Settings
|
||||||
const safetySettings = [
|
const safetySettings = [
|
||||||
@@ -142,7 +160,7 @@ export function transformClaudeRequestIn(
|
|||||||
|
|
||||||
// Inject googleSearch tool if needed (and not already done by buildTools)
|
// Inject googleSearch tool if needed (and not already done by buildTools)
|
||||||
if (config.injectGoogleSearch && !hasWebSearchTool) {
|
if (config.injectGoogleSearch && !hasWebSearchTool) {
|
||||||
injectGoogleSearchTool(innerRequest);
|
injectGoogleSearchTool(innerRequest, config.finalModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject imageConfig if present (for image generation models)
|
// Inject imageConfig if present (for image generation models)
|
||||||
@@ -165,11 +183,12 @@ export function transformClaudeRequestIn(
|
|||||||
|
|
||||||
const normalizedProjectId = projectId?.trim();
|
const normalizedProjectId = projectId?.trim();
|
||||||
|
|
||||||
|
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||||
const body: GeminiInternalRequest = {
|
const body: GeminiInternalRequest = {
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
request: innerRequest as GeminiInternalRequest['request'],
|
request: innerRequest as GeminiInternalRequest['request'],
|
||||||
model: config.finalModel,
|
model: config.finalModel,
|
||||||
userAgent: 'antigravity',
|
userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion),
|
||||||
requestType: config.requestType,
|
requestType: config.requestType,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,7 +213,7 @@ function resolveRequestConfig(
|
|||||||
tools?: Tool[],
|
tools?: Tool[],
|
||||||
): RequestConfig {
|
): RequestConfig {
|
||||||
// 1. Image Generation Check
|
// 1. Image Generation Check
|
||||||
if (mappedModel.startsWith('gemini-3-pro-image')) {
|
if (isGeminiImageModel(mappedModel)) {
|
||||||
const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel);
|
const { imageConfig, parsedBaseModel } = parseImageConfig(originalModel);
|
||||||
return {
|
return {
|
||||||
requestType: 'image_gen',
|
requestType: 'image_gen',
|
||||||
@@ -212,18 +231,11 @@ function resolveRequestConfig(
|
|||||||
const enableNetworking = isOnlineSuffix || hasNetworkingTool;
|
const enableNetworking = isOnlineSuffix || hasNetworkingTool;
|
||||||
|
|
||||||
let finalModel = mappedModel.replace(/-online$/, '');
|
let finalModel = mappedModel.replace(/-online$/, '');
|
||||||
|
finalModel = normalizeGeminiModelAlias(finalModel);
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableNetworking) {
|
if (enableNetworking) {
|
||||||
if (finalModel !== 'gemini-2.5-flash') {
|
if (finalModel !== 'gemini-3-flash') {
|
||||||
finalModel = 'gemini-2.5-flash';
|
finalModel = 'gemini-3-flash';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +272,50 @@ function parseImageConfig(modelName: string): {
|
|||||||
return { imageConfig: config, parsedBaseModel: 'gemini-3-pro-image' };
|
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
|
* Detects if networking tools are present
|
||||||
* Checks tool list for web search related tools
|
* Checks tool list for web search related tools
|
||||||
@@ -274,20 +330,23 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea
|
|||||||
'google_search',
|
'google_search',
|
||||||
'web_search_20250305',
|
'web_search_20250305',
|
||||||
'google_search_retrieval',
|
'google_search_retrieval',
|
||||||
|
'builtin_web_search',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
// Claude Tool format
|
// 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;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI nested format (runtime check)
|
// OpenAI nested format (runtime check)
|
||||||
const openaiTool = tool as { function?: { name?: string } };
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +370,7 @@ function detectsNetworkingTool(tools?: (Tool | GeminiToolDeclaration)[]): boolea
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }, mappedModel?: string) {
|
||||||
* Inject Google Search Tool
|
|
||||||
* Adds googleSearch tool to the request
|
|
||||||
*/
|
|
||||||
function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
|
||||||
if (!body.tools) {
|
if (!body.tools) {
|
||||||
body.tools = [];
|
body.tools = [];
|
||||||
}
|
}
|
||||||
@@ -323,7 +378,9 @@ function injectGoogleSearchTool(body: { tools?: GeminiToolDeclaration[] }) {
|
|||||||
|
|
||||||
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
const hasFunctions = toolsArr.some((t) => t.functionDeclarations);
|
||||||
if (hasFunctions) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +408,7 @@ function buildSystemInstruction(
|
|||||||
let hasIdentityDirective = false;
|
let hasIdentityDirective = false;
|
||||||
|
|
||||||
if (system) {
|
if (system) {
|
||||||
if (typeof system === 'string') {
|
if (isString(system)) {
|
||||||
if (system.includes(identityMarker)) {
|
if (system.includes(identityMarker)) {
|
||||||
hasIdentityDirective = true;
|
hasIdentityDirective = true;
|
||||||
}
|
}
|
||||||
@@ -370,7 +427,7 @@ function buildSystemInstruction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (system) {
|
if (system) {
|
||||||
if (typeof system === 'string') {
|
if (isString(system)) {
|
||||||
parts.push({ text: system });
|
parts.push({ text: system });
|
||||||
} else if (Array.isArray(system)) {
|
} else if (Array.isArray(system)) {
|
||||||
for (const block of system) {
|
for (const block of system) {
|
||||||
@@ -437,6 +494,7 @@ function buildContents(
|
|||||||
toolIdToName: Map<string, string>,
|
toolIdToName: Map<string, string>,
|
||||||
isThinkingEnabled: boolean,
|
isThinkingEnabled: boolean,
|
||||||
allowDummyThought: boolean,
|
allowDummyThought: boolean,
|
||||||
|
mappedModel: string,
|
||||||
): GeminiContent[] {
|
): GeminiContent[] {
|
||||||
const contents: GeminiContent[] = [];
|
const contents: GeminiContent[] = [];
|
||||||
let lastThoughtSignature: string | null = null;
|
let lastThoughtSignature: string | null = null;
|
||||||
@@ -460,7 +518,7 @@ function buildContents(
|
|||||||
|
|
||||||
for (const block of contentBlocks) {
|
for (const block of contentBlocks) {
|
||||||
if (block.type === 'text') {
|
if (block.type === 'text') {
|
||||||
if (block.text && block.text !== '(no content)' && block.text.trim() !== '')
|
if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim()))
|
||||||
parts.push({ text: block.text.trim() });
|
parts.push({ text: block.text.trim() });
|
||||||
} else if (block.type === 'thinking') {
|
} else if (block.type === 'thinking') {
|
||||||
const part: any = { text: block.thinking, thought: true };
|
const part: any = { text: block.thinking, thought: true };
|
||||||
@@ -480,18 +538,22 @@ function buildContents(
|
|||||||
cleanJsonSchema(part);
|
cleanJsonSchema(part);
|
||||||
toolIdToName.set(block.id, block.name);
|
toolIdToName.set(block.id, block.name);
|
||||||
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
|
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);
|
parts.push(part);
|
||||||
} else if (block.type === 'tool_result') {
|
} else if (block.type === 'tool_result') {
|
||||||
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||||
let mergedContent = '';
|
let mergedContent = '';
|
||||||
if (typeof block.content === 'string') mergedContent = block.content;
|
if (isString(block.content)) mergedContent = block.content;
|
||||||
else if (Array.isArray(block.content))
|
else if (Array.isArray(block.content))
|
||||||
mergedContent = block.content
|
mergedContent = block.content
|
||||||
.filter((b: any) => b.type === 'text')
|
.filter((b: any) => b.type === 'text')
|
||||||
.map((b: any) => b.text)
|
.map((b: any) => b.text)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
if (mergedContent.trim().length === 0)
|
if (isEmpty(mergedContent.trim()))
|
||||||
mergedContent = block.is_error
|
mergedContent = block.is_error
|
||||||
? 'Tool execution failed with no output.'
|
? 'Tool execution failed with no output.'
|
||||||
: 'Command executed successfully.';
|
: 'Command executed successfully.';
|
||||||
@@ -524,6 +586,7 @@ function buildContents(
|
|||||||
function buildTools(
|
function buildTools(
|
||||||
tools: Tool[] | undefined,
|
tools: Tool[] | undefined,
|
||||||
hasWebSearch: boolean,
|
hasWebSearch: boolean,
|
||||||
|
mappedModel: string,
|
||||||
): GeminiToolDeclaration[] | null {
|
): GeminiToolDeclaration[] | null {
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
return null;
|
return null;
|
||||||
@@ -535,14 +598,15 @@ function buildTools(
|
|||||||
if (
|
if (
|
||||||
tool.name === 'web_search' ||
|
tool.name === 'web_search' ||
|
||||||
tool.name === 'google_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;
|
hasGoogleSearch = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (tool.name) {
|
if (tool.name) {
|
||||||
const inputSchema = tool.input_schema || { type: 'object', properties: {} };
|
const inputSchema = toToolSchema(tool.input_schema);
|
||||||
cleanJsonSchema(inputSchema);
|
|
||||||
functionDeclarations.push({
|
functionDeclarations.push({
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
@@ -551,15 +615,20 @@ function buildTools(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolObj: GeminiToolDeclaration = {};
|
const toolList: GeminiToolDeclaration[] = [];
|
||||||
if (functionDeclarations.length > 0) {
|
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) {
|
} else if (hasGoogleSearch) {
|
||||||
toolObj.googleSearch = {};
|
toolList.push({ googleSearch: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(toolObj).length > 0) {
|
if (toolList.length > 0) {
|
||||||
return [toolObj];
|
return toolList;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -572,32 +641,53 @@ function buildGenerationConfig(
|
|||||||
claudeReq: ClaudeRequest,
|
claudeReq: ClaudeRequest,
|
||||||
hasWebSearch: boolean,
|
hasWebSearch: boolean,
|
||||||
mappedModel: string,
|
mappedModel: string,
|
||||||
|
isThinkingEnabled: boolean,
|
||||||
): GenerationConfig {
|
): GenerationConfig {
|
||||||
const source = String(claudeReq.metadata?.source || '').toLowerCase();
|
const source = String(claudeReq.metadata?.source || '').toLowerCase();
|
||||||
const isOpenAIPath = source === 'openai';
|
const isOpenAIPath = source === 'openai';
|
||||||
const config: GenerationConfig = {};
|
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) {
|
if (isOpenAIPath) {
|
||||||
config.temperature = claudeReq.temperature ?? 1.0;
|
config.temperature = claudeReq.temperature ?? 1.0;
|
||||||
config.topP = claudeReq.top_p ?? 0.95;
|
config.topP = claudeReq.top_p ?? 0.95;
|
||||||
if (claudeReq.max_tokens !== undefined) {
|
if (claudeReq.max_tokens !== undefined) {
|
||||||
config.maxOutputTokens = claudeReq.max_tokens;
|
config.maxOutputTokens = claudeReq.max_tokens;
|
||||||
|
} else {
|
||||||
|
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
|
||||||
}
|
}
|
||||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||||
config.stopSequences = claudeReq.stop_sequences;
|
config.stopSequences = claudeReq.stop_sequences;
|
||||||
}
|
}
|
||||||
|
if (isThinkingEnabled) {
|
||||||
|
config.thinkingConfig = buildThinkingConfig();
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (claudeReq.thinking?.type === 'enabled') {
|
if (isThinkingEnabled) {
|
||||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
config.thinkingConfig = buildThinkingConfig();
|
||||||
if (claudeReq.thinking.budget_tokens) {
|
|
||||||
let budget = claudeReq.thinking.budget_tokens;
|
|
||||||
const isFlash = hasWebSearch || mappedModel.includes('gemini-2.5-flash');
|
|
||||||
if (isFlash) budget = Math.min(budget, 24576);
|
|
||||||
thinkingConfig.thinkingBudget = budget;
|
|
||||||
}
|
|
||||||
config.thinkingConfig = thinkingConfig;
|
|
||||||
}
|
}
|
||||||
if (claudeReq.temperature !== undefined) {
|
if (claudeReq.temperature !== undefined) {
|
||||||
config.temperature = claudeReq.temperature;
|
config.temperature = claudeReq.temperature;
|
||||||
@@ -623,7 +713,7 @@ function buildGenerationConfig(
|
|||||||
function deepCleanUndefined(obj: unknown): void {
|
function deepCleanUndefined(obj: unknown): void {
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
obj.forEach(deepCleanUndefined);
|
obj.forEach(deepCleanUndefined);
|
||||||
} else if (obj && typeof obj === 'object') {
|
} else if (isPlainObject(obj)) {
|
||||||
const record = obj as Record<string, unknown>;
|
const record = obj as Record<string, unknown>;
|
||||||
Object.keys(record).forEach((key) => {
|
Object.keys(record).forEach((key) => {
|
||||||
if (record[key] === undefined) delete record[key];
|
if (record[key] === undefined) delete record[key];
|
||||||
|
|||||||
@@ -165,21 +165,21 @@ class NonStreamingProcessor {
|
|||||||
let groundingText = '';
|
let groundingText = '';
|
||||||
|
|
||||||
if (grounding.webSearchQueries && grounding.webSearchQueries.length > 0) {
|
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) {
|
if (grounding.groundingChunks) {
|
||||||
const links: string[] = [];
|
const links: string[] = [];
|
||||||
grounding.groundingChunks.forEach((chunk, index) => {
|
grounding.groundingChunks.forEach((chunk, index) => {
|
||||||
if (chunk.web) {
|
if (chunk.web) {
|
||||||
const title = chunk.web.title || '网页来源';
|
const title = chunk.web.title || 'Web source';
|
||||||
const uri = chunk.web.uri || '#';
|
const uri = chunk.web.uri || '#';
|
||||||
links.push(`[${index + 1}] [${title}](${uri})`);
|
links.push(`[${index + 1}] [${title}](${uri})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (links.length > 0) {
|
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
|
// Process grounding (web search) -> convert to Markdown text block
|
||||||
let groundingText = '';
|
let groundingText = '';
|
||||||
if (this.webSearchQuery) {
|
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) {
|
if (this.groundingChunks && this.groundingChunks.length > 0) {
|
||||||
const links: string[] = [];
|
const links: string[] = [];
|
||||||
this.groundingChunks.forEach((chunk, i) => {
|
this.groundingChunks.forEach((chunk, i) => {
|
||||||
if (chunk.web) {
|
if (chunk.web) {
|
||||||
const title = chunk.web.title || '网页来源';
|
const title = chunk.web.title || 'Web source';
|
||||||
const uri = chunk.web.uri || '#';
|
const uri = chunk.web.uri || '#';
|
||||||
links.push(`[${i + 1}] [${title}](${uri})`);
|
links.push(`[${i + 1}] [${title}](${uri})`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (links.length > 0) {
|
if (links.length > 0) {
|
||||||
groundingText += `\n\n**🌐 来源引文:**\n` + links.join('\n');
|
groundingText += `\n\n**🌐 Citations:**\n` + links.join('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isArray, isBoolean, isNumber, isObjectLike, isString } from 'lodash-es';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively cleans JSON Schema to meet Gemini interface requirements
|
* Recursively cleans JSON Schema to meet Gemini interface requirements
|
||||||
*
|
*
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
export function cleanJsonSchema(value: any) {
|
export function cleanJsonSchema(value: any) {
|
||||||
// 0. Preprocessing: Expand $ref (Schema Flattening)
|
// 0. Preprocessing: Expand $ref (Schema Flattening)
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
if (isObjectLike(value) && !isArray(value)) {
|
||||||
const defs: Record<string, any> = {};
|
const defs: Record<string, any> = {};
|
||||||
|
|
||||||
// Extract $defs or definitions
|
// Extract $defs or definitions
|
||||||
@@ -32,14 +34,38 @@ export function cleanJsonSchema(value: any) {
|
|||||||
cleanJsonSchemaRecursive(value);
|
cleanJsonSchemaRecursive(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeObjectJsonSchema(schema: unknown): Record<string, unknown> {
|
||||||
|
const fallbackSchema: Record<string, unknown> = { type: 'object', properties: {} };
|
||||||
|
if (!isObjectLike(schema) || isArray(schema)) {
|
||||||
|
return fallbackSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSchema = JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||||
|
cleanJsonSchema(normalizedSchema);
|
||||||
|
|
||||||
|
if (!isString(normalizedSchema.type)) {
|
||||||
|
normalizedSchema.type = 'object';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedSchema.type === 'object' &&
|
||||||
|
(!normalizedSchema.properties ||
|
||||||
|
!isObjectLike(normalizedSchema.properties) ||
|
||||||
|
isArray(normalizedSchema.properties))
|
||||||
|
) {
|
||||||
|
normalizedSchema.properties = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedSchema;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively expand $ref
|
* Recursively expand $ref
|
||||||
*/
|
*/
|
||||||
function flattenRefs(map: any, defs: Record<string, any>) {
|
function flattenRefs(map: any, defs: Record<string, any>) {
|
||||||
if (!map || typeof map !== 'object') return;
|
if (!isObjectLike(map)) return;
|
||||||
|
|
||||||
// 检查并替换 $ref
|
// Check and replace $ref
|
||||||
if (typeof map['$ref'] === 'string') {
|
if (isString(map['$ref'])) {
|
||||||
const refPath = map['$ref'];
|
const refPath = map['$ref'];
|
||||||
// Parse reference name (e.g. #/$defs/MyType -> MyType)
|
// Parse reference name (e.g. #/$defs/MyType -> MyType)
|
||||||
const parts = refPath.split('/');
|
const parts = refPath.split('/');
|
||||||
@@ -50,7 +76,7 @@ function flattenRefs(map: any, defs: Record<string, any>) {
|
|||||||
// $ref nodes should not have other properties, remove $ref directly
|
// $ref nodes should not have other properties, remove $ref directly
|
||||||
delete map['$ref'];
|
delete map['$ref'];
|
||||||
|
|
||||||
if (defSchema && typeof defSchema === 'object') {
|
if (isObjectLike(defSchema)) {
|
||||||
for (const [k, v] of Object.entries(defSchema)) {
|
for (const [k, v] of Object.entries(defSchema)) {
|
||||||
// Only insert if the key does not exist in current map (avoid overwrite)
|
// Only insert if the key does not exist in current map (avoid overwrite)
|
||||||
if (map[k] === undefined) {
|
if (map[k] === undefined) {
|
||||||
@@ -69,7 +95,7 @@ function flattenRefs(map: any, defs: Record<string, any>) {
|
|||||||
for (const k in map) {
|
for (const k in map) {
|
||||||
if (Object.prototype.hasOwnProperty.call(map, k)) {
|
if (Object.prototype.hasOwnProperty.call(map, k)) {
|
||||||
const v = map[k];
|
const v = map[k];
|
||||||
if (typeof v === 'object' && v !== null) {
|
if (isObjectLike(v)) {
|
||||||
flattenRefs(v, defs);
|
flattenRefs(v, defs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,11 +103,11 @@ function flattenRefs(map: any, defs: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanJsonSchemaRecursive(value: any) {
|
function cleanJsonSchemaRecursive(value: any) {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!isObjectLike(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (isArray(value)) {
|
||||||
// Array: Recursively process each element
|
// Array: Recursively process each element
|
||||||
for (const v of value) {
|
for (const v of value) {
|
||||||
cleanJsonSchemaRecursive(v);
|
cleanJsonSchemaRecursive(v);
|
||||||
@@ -118,7 +144,7 @@ function cleanJsonSchemaRecursive(value: any) {
|
|||||||
if (map[field] !== undefined) {
|
if (map[field] !== undefined) {
|
||||||
const val = map[field];
|
const val = map[field];
|
||||||
// Only migrate if value is primitive type
|
// Only migrate if value is primitive type
|
||||||
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
if (isString(val) || isNumber(val) || isBoolean(val)) {
|
||||||
constraints.push(`${label}: ${val}`);
|
constraints.push(`${label}: ${val}`);
|
||||||
delete map[field];
|
delete map[field];
|
||||||
} else {
|
} else {
|
||||||
@@ -164,13 +190,13 @@ function cleanJsonSchemaRecursive(value: any) {
|
|||||||
// 5. Handle type field (Gemini requires single lowercase string)
|
// 5. Handle type field (Gemini requires single lowercase string)
|
||||||
if (map['type']) {
|
if (map['type']) {
|
||||||
const typeVal = map['type'];
|
const typeVal = map['type'];
|
||||||
if (typeof typeVal === 'string') {
|
if (isString(typeVal)) {
|
||||||
map['type'] = typeVal.toLowerCase();
|
map['type'] = typeVal.toLowerCase();
|
||||||
} else if (Array.isArray(typeVal)) {
|
} else if (isArray(typeVal)) {
|
||||||
// Union type downgrade: take the first non-null type
|
// Union type downgrade: take the first non-null type
|
||||||
let selectedType = 'string';
|
let selectedType = 'string';
|
||||||
for (const item of typeVal) {
|
for (const item of typeVal) {
|
||||||
if (typeof item === 'string' && item !== 'null') {
|
if (isString(item) && item !== 'null') {
|
||||||
selectedType = item.toLowerCase();
|
selectedType = item.toLowerCase();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
|
import { isEmpty, isString } from 'lodash-es';
|
||||||
import { logger } from '../../utils/logger';
|
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> = {
|
const CLAUDE_TO_GEMINI: Record<string, string> = {
|
||||||
// Directly supported models
|
// 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-opus-4-6-thinking': 'claude-opus-4-6-thinking',
|
||||||
'claude-sonnet-4-5': 'claude-sonnet-4-5',
|
'gemini-3.1-pro-low': 'gemini-3.1-pro-low',
|
||||||
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking',
|
'gemini-3.1-pro-high': 'gemini-3.1-pro-high',
|
||||||
|
'gemini-3-flash': 'gemini-3-flash',
|
||||||
|
|
||||||
// Alias mappings
|
// Alias mappings
|
||||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4-5-thinking',
|
'claude-sonnet-4-6': 'claude-sonnet-4-6-thinking',
|
||||||
'claude-3-5-sonnet-20241022': 'claude-sonnet-4-5',
|
'claude-sonnet-4-6-20260219': 'claude-sonnet-4-6-thinking',
|
||||||
'claude-3-5-sonnet-20240620': 'claude-sonnet-4-5',
|
'claude-sonnet-4-5': 'claude-sonnet-4-6-thinking',
|
||||||
'claude-opus-4': 'claude-opus-4-5-thinking',
|
'claude-sonnet-4-5-thinking': 'claude-sonnet-4-6-thinking',
|
||||||
'claude-opus-4-5-20251101': 'claude-opus-4-5-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': 'claude-opus-4-6-thinking',
|
||||||
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
|
'claude-opus-4-6-20260201': 'claude-opus-4-6-thinking',
|
||||||
'claude-haiku-4': 'claude-sonnet-4-5',
|
'claude-haiku-4': 'claude-sonnet-4-6-thinking',
|
||||||
'claude-3-haiku-20240307': 'claude-sonnet-4-5',
|
'claude-3-haiku-20240307': 'claude-sonnet-4-6-thinking',
|
||||||
'claude-haiku-4-5-20251001': 'claude-sonnet-4-5',
|
'claude-haiku-4-5-20251001': 'claude-sonnet-4-6-thinking',
|
||||||
|
|
||||||
// OpenAI Protocol Mapping
|
// OpenAI Protocol Mapping
|
||||||
'gpt-4': 'gemini-3-flash',
|
'gpt-4': 'gemini-3-flash',
|
||||||
@@ -42,12 +57,17 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
|||||||
|
|
||||||
// Gemini Protocol Mapping
|
// Gemini Protocol Mapping
|
||||||
'gemini-2.5-flash-lite': 'gemini-3-flash',
|
'gemini-2.5-flash-lite': 'gemini-3-flash',
|
||||||
'gemini-3-pro-low': 'gemini-3-pro-preview',
|
'gemini-3.1-pro-preview': 'gemini-3.1-pro-high',
|
||||||
'gemini-3-pro-high': 'gemini-3-pro-preview',
|
'gemini-3.1-pro': 'gemini-3.1-pro-high',
|
||||||
'gemini-3-pro-preview': 'gemini-3-pro-preview',
|
'gemini-3.0-pro': 'gemini-3.1-pro-high',
|
||||||
'gemini-3-pro': 'gemini-3-pro-preview',
|
'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-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',
|
'gemini-3-pro-image': 'gemini-3-pro-image',
|
||||||
'internal-background-task': 'gemini-3-flash',
|
'internal-background-task': 'gemini-3-flash',
|
||||||
};
|
};
|
||||||
@@ -55,17 +75,50 @@ const CLAUDE_TO_GEMINI: Record<string, string> = {
|
|||||||
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
|
const DYNAMIC_IMAGE_BASE_MODEL = 'gemini-3-pro-image';
|
||||||
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
|
const DYNAMIC_IMAGE_RESOLUTIONS = ['', '-2k', '-4k'];
|
||||||
const DYNAMIC_IMAGE_RATIOS = ['', '-1x1', '-4x3', '-3x4', '-16x9', '-9x16', '-21x9'];
|
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_CREATED_AT = 1770652800;
|
||||||
|
|
||||||
export const MODEL_LIST_OWNER = 'antigravity';
|
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[] {
|
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 (!isString(oldModel) || !isString(newModel)) {
|
||||||
|
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>();
|
const modelIds = new Set<string>();
|
||||||
|
|
||||||
for (const modelId of getSupportedModels()) {
|
for (const modelId of getSupportedModels()) {
|
||||||
@@ -86,10 +139,21 @@ export function getAllDynamicModels(customMapping: Record<string, string> = {}):
|
|||||||
modelIds.add(modelId);
|
modelIds.add(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...modelIds].filter((id) => !/gemini-[12](\.|$|-)/.test(id)).sort();
|
if (dynamicModelIds) {
|
||||||
|
for (const dynamicModelId of dynamicModelIds) {
|
||||||
|
if (isString(dynamicModelId) && !isEmpty(dynamicModelId.trim())) {
|
||||||
|
modelIds.add(dynamicModelId.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...modelIds].filter((id) => !shouldHideDeprecatedModelFromList(id)).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapClaudeModelToGemini(input: string): string {
|
export function mapClaudeModelToGemini(input: string): string {
|
||||||
|
if (!isString(input) || isEmpty(input)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const mappedModel = CLAUDE_TO_GEMINI[input];
|
const mappedModel = CLAUDE_TO_GEMINI[input];
|
||||||
if (mappedModel) {
|
if (mappedModel) {
|
||||||
return mappedModel;
|
return mappedModel;
|
||||||
@@ -98,6 +162,11 @@ export function mapClaudeModelToGemini(input: string): string {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeGeminiModelAlias(modelId: string): string {
|
||||||
|
const normalizedModelId = modelId.trim().toLowerCase();
|
||||||
|
return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core Model Routing Engine
|
* Core Model Routing Engine
|
||||||
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
|
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
|
||||||
@@ -108,6 +177,14 @@ export function resolveModelRoute(
|
|||||||
openaiMapping: Record<string, string>,
|
openaiMapping: Record<string, string>,
|
||||||
anthropicMapping: Record<string, string>,
|
anthropicMapping: Record<string, 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)
|
// 1. Check custom exact mapping (Highest priority)
|
||||||
if (customMapping[originalModel]) {
|
if (customMapping[originalModel]) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -194,3 +271,31 @@ export function resolveModelRoute(
|
|||||||
// 4. Fall through to system default mapping logic
|
// 4. Fall through to system default mapping logic
|
||||||
return mapClaudeModelToGemini(originalModel);
|
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,41 @@
|
|||||||
|
import modelSpecsJson from './model-specs';
|
||||||
|
import { isNumber } from 'lodash-es';
|
||||||
|
|
||||||
|
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 (isNumber(fromSpec) && 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 (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec >= 0) {
|
||||||
|
return Math.floor(fromSpec);
|
||||||
|
}
|
||||||
|
return DEFAULT_THINKING_BUDGET;
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
import { isNumber } from 'lodash-es';
|
||||||
import { QuotaData, LoadProjectResponse, QuotaApiResponse } from './types';
|
import { QuotaData, LoadProjectResponse, QuotaApiResponse } from './types';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
// Constants
|
// 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 CLOUD_CODE_BASE_URL = 'https://cloudcode-pa.googleapis.com';
|
||||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; // Keeping the same UA as source
|
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; // Keeping the same UA as source
|
||||||
|
|
||||||
@@ -83,15 +88,16 @@ export class QuotaService {
|
|||||||
const finalProjectId = projectId;
|
const finalProjectId = projectId;
|
||||||
|
|
||||||
const client = this.createClient();
|
const client = this.createClient();
|
||||||
const payload = { project: finalProjectId };
|
const payload = finalProjectId ? { project: finalProjectId } : {};
|
||||||
const url = QUOTA_API_URL;
|
let lastError: Error | null = null;
|
||||||
const maxRetries = 3;
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const response = await client.post<QuotaApiResponse>(url, payload, {
|
const response = await client.post<QuotaApiResponse>(endpoint, payload, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
'User-Agent': USER_AGENT,
|
'User-Agent': USER_AGENT,
|
||||||
@@ -127,8 +133,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) {
|
} catch (error: any) {
|
||||||
|
let shouldFallback = true;
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
let text = '';
|
let text = '';
|
||||||
@@ -147,23 +159,36 @@ export class QuotaService {
|
|||||||
isForbidden: true,
|
isForbidden: true,
|
||||||
subscriptionTier: subscriptionTier,
|
subscriptionTier: subscriptionTier,
|
||||||
},
|
},
|
||||||
projectId: projectId,
|
projectId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn(`API Error: ${status} - ${text} (Attempt ${attempt}/${maxRetries})`);
|
if (hasNextEndpoint && (status === 429 || (isNumber(status) && 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 = !isNumber(status);
|
||||||
} else {
|
} 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));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
} else {
|
} 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 {
|
export interface ThinkingConfig {
|
||||||
type: 'enabled' | string;
|
type: 'enabled' | string;
|
||||||
budget_tokens?: number;
|
budget_tokens?: number;
|
||||||
|
effort?: 'low' | 'medium' | 'high' | 'max' | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SystemPrompt = string | SystemBlock[];
|
export type SystemPrompt = string | SystemBlock[];
|
||||||
@@ -194,6 +195,8 @@ export interface ThinkingGeminiConfig {
|
|||||||
includeThoughts: boolean;
|
includeThoughts: boolean;
|
||||||
/** Thinking token budget */
|
/** Thinking token budget */
|
||||||
thinkingBudget?: number;
|
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>;
|
models: Record<string, ModelQuotaInfo>;
|
||||||
isForbidden: boolean;
|
isForbidden: boolean;
|
||||||
subscriptionTier?: string;
|
subscriptionTier?: string;
|
||||||
|
model_forwarding_rules?: Record<string, string>;
|
||||||
|
is_forbidden?: boolean;
|
||||||
|
subscription_tier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelQuotaInfo {
|
export interface ModelQuotaInfo {
|
||||||
percentage: number;
|
percentage: number;
|
||||||
resetTime: string;
|
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 {
|
export interface LoadProjectResponse {
|
||||||
@@ -327,7 +341,21 @@ export interface Tier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotaApiResponse {
|
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 ---
|
// --- Response Models ---
|
||||||
|
|||||||
+268
-4
@@ -1,6 +1,7 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
import vi from './vi';
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
@@ -12,12 +13,19 @@ i18n
|
|||||||
caches: ['localStorage'],
|
caches: ['localStorage'],
|
||||||
lookupLocalStorage: 'lang',
|
lookupLocalStorage: 'lang',
|
||||||
},
|
},
|
||||||
supportedLngs: ['en', 'zh-CN', 'ru'],
|
supportedLngs: ['en', 'zh-CN', 'ru', 'vi'],
|
||||||
load: 'currentOnly', // Only load the exact language code, not variants
|
load: 'currentOnly', // Only load the exact language code, not variants
|
||||||
resources: {
|
resources: {
|
||||||
en: {
|
en: {
|
||||||
translation: {
|
translation: {
|
||||||
appName: 'Antigravity Manager',
|
appName: 'Antigravity Manager',
|
||||||
|
common: {
|
||||||
|
loading: 'Loading...',
|
||||||
|
error: 'Error',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
notAvailable: 'N/A',
|
||||||
|
openMenu: 'Open menu',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
checking: 'Checking status...',
|
checking: 'Checking status...',
|
||||||
running: 'Antigravity is running in background',
|
running: 'Antigravity is running in background',
|
||||||
@@ -84,6 +92,7 @@ i18n
|
|||||||
english: 'English',
|
english: 'English',
|
||||||
chinese: 'Chinese (Simplified)',
|
chinese: 'Chinese (Simplified)',
|
||||||
russian: 'Russian',
|
russian: 'Russian',
|
||||||
|
vietnamese: 'Vietnamese',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: 'About',
|
title: 'About',
|
||||||
@@ -93,6 +102,15 @@ i18n
|
|||||||
platform: 'Platform',
|
platform: 'Platform',
|
||||||
license: 'License',
|
license: 'License',
|
||||||
openLogDir: 'Open',
|
openLogDir: 'Open',
|
||||||
|
toast: {
|
||||||
|
saved: {
|
||||||
|
title: 'Settings saved',
|
||||||
|
description: 'Your configuration has been updated.',
|
||||||
|
},
|
||||||
|
saveFailed: {
|
||||||
|
title: 'Error saving settings',
|
||||||
|
},
|
||||||
|
},
|
||||||
account: {
|
account: {
|
||||||
title: 'Account Settings',
|
title: 'Account Settings',
|
||||||
description: 'Configure automatic account refresh and sync.',
|
description: 'Configure automatic account refresh and sync.',
|
||||||
@@ -118,6 +136,16 @@ i18n
|
|||||||
restart_note:
|
restart_note:
|
||||||
'Changes to error reporting will take effect after restarting the application.',
|
'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',
|
||||||
|
saveFailed: 'Failed to save notification settings',
|
||||||
|
thresholdSaveFailed: 'Failed to save threshold setting',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'Upstream Proxy',
|
title: 'Upstream Proxy',
|
||||||
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
description: 'Configure a proxy for outgoing requests to Google/Gemini APIs.',
|
||||||
@@ -132,7 +160,7 @@ i18n
|
|||||||
claudeKeyword: 'Claude Model (Keyword)',
|
claudeKeyword: 'Claude Model (Keyword)',
|
||||||
targetGemini: 'Target Gemini Model',
|
targetGemini: 'Target Gemini Model',
|
||||||
addPlaceholderKey: 'e.g. op-3',
|
addPlaceholderKey: 'e.g. op-3',
|
||||||
addPlaceholderValue: 'e.g. gemini-2.5-flash',
|
addPlaceholderValue: 'e.g. gemini-3-flash',
|
||||||
noMappings: 'No custom mappings defined.',
|
noMappings: 'No custom mappings defined.',
|
||||||
mapsTo: 'Maps to',
|
mapsTo: 'Maps to',
|
||||||
default: 'Default',
|
default: 'Default',
|
||||||
@@ -258,10 +286,13 @@ i18n
|
|||||||
twoCol: '2 Columns',
|
twoCol: '2 Columns',
|
||||||
threeCol: '3 Columns',
|
threeCol: '3 Columns',
|
||||||
list: 'List',
|
list: 'List',
|
||||||
|
compact: 'Compact',
|
||||||
},
|
},
|
||||||
authDialog: {
|
authDialog: {
|
||||||
title: 'Add Google Account',
|
title: 'Add Google Account',
|
||||||
description: 'To add an account, you need to authorize the application.',
|
description: 'To add an account, you need to authorize the application.',
|
||||||
|
oauthClient: 'OAuth Client',
|
||||||
|
oauthClientPlaceholder: 'Select OAuth client',
|
||||||
openLogin: 'Open Login Page',
|
openLogin: 'Open Login Page',
|
||||||
authCode: 'Authorization Code',
|
authCode: 'Authorization Code',
|
||||||
placeholder: 'Paste the code starting with 4/...',
|
placeholder: 'Paste the code starting with 4/...',
|
||||||
@@ -273,6 +304,9 @@ i18n
|
|||||||
active: 'Active',
|
active: 'Active',
|
||||||
use: 'Use',
|
use: 'Use',
|
||||||
rateLimited: 'Rate Limited',
|
rateLimited: 'Rate Limited',
|
||||||
|
validationRiskControlled: 'Risk / Rate Limited',
|
||||||
|
validationOAuthReauthRequired: 'OAuth Re-auth Required',
|
||||||
|
validationRequired: 'Verification Required',
|
||||||
left: 'left',
|
left: 'left',
|
||||||
used: 'Used',
|
used: 'Used',
|
||||||
unknown: 'Unknown User',
|
unknown: 'Unknown User',
|
||||||
@@ -289,6 +323,14 @@ i18n
|
|||||||
gemini3Ready: 'Gemini 3 Ready',
|
gemini3Ready: 'Gemini 3 Ready',
|
||||||
groupGoogleGemini: 'Google Gemini',
|
groupGoogleGemini: 'Google Gemini',
|
||||||
groupAnthropicClaude: 'Anthropic Claude',
|
groupAnthropicClaude: 'Anthropic Claude',
|
||||||
|
proxy: 'Proxy',
|
||||||
|
proxyPlaceholder: 'e.g. http://127.0.0.1:7890',
|
||||||
|
proxySaved: 'Proxy saved',
|
||||||
|
noProxy: 'No proxy',
|
||||||
|
aiCredits: 'AI Credits',
|
||||||
|
aiCreditsValue: '{{amount}} credits',
|
||||||
|
creditsExpiry: 'expires {{date}}',
|
||||||
|
modelVisibility: 'Model Visibility',
|
||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
title: 'Identity Profile',
|
title: 'Identity Profile',
|
||||||
@@ -348,6 +390,20 @@ i18n
|
|||||||
autoSwitchOn: 'Auto-Switch Enabled',
|
autoSwitchOn: 'Auto-Switch Enabled',
|
||||||
autoSwitchOff: 'Auto-Switch Disabled',
|
autoSwitchOff: 'Auto-Switch Disabled',
|
||||||
updateSettingsFailed: 'Failed to update settings',
|
updateSettingsFailed: 'Failed to update settings',
|
||||||
|
actionFailed: 'Action failed',
|
||||||
|
startAuthFailed: 'Failed to start sign-in flow',
|
||||||
|
refreshCreditsAvailable: 'AI credits: {{amount}}',
|
||||||
|
refreshCreditsUnavailable: 'AI credits not available for this refresh.',
|
||||||
|
batchRefreshSuccess: 'Successfully refreshed {{count}} accounts.',
|
||||||
|
batchRefreshPartial: {
|
||||||
|
title: 'Refresh completed with issues',
|
||||||
|
description: 'Refreshed {{successful}} accounts, {{failed}} failed.',
|
||||||
|
},
|
||||||
|
batchDeleteSuccess: 'Successfully deleted {{count}} accounts.',
|
||||||
|
batchDeletePartial: {
|
||||||
|
title: 'Deletion completed with issues',
|
||||||
|
description: 'Deleted {{successful}} accounts, {{failed}} failed.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
batch: {
|
batch: {
|
||||||
selected: 'Selected {{count}}',
|
selected: 'Selected {{count}}',
|
||||||
@@ -357,12 +413,42 @@ i18n
|
|||||||
clear: 'Clear Selection',
|
clear: 'Clear Selection',
|
||||||
confirmDelete: 'Are you sure you want to delete {{count}} accounts?',
|
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...',
|
||||||
|
fileTooLarge: 'File size exceeds 5MB limit',
|
||||||
|
invalidJson: 'Invalid JSON file format',
|
||||||
|
readFileFailed: 'Failed to read file',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'API Proxy',
|
title: 'API Proxy',
|
||||||
description: 'Manage the local API proxy service.',
|
description: 'Manage the local API proxy service.',
|
||||||
save: 'Save Settings',
|
save: 'Save Settings',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
|
copied: 'Copied!',
|
||||||
regenerate: 'Regenerate',
|
regenerate: 'Regenerate',
|
||||||
regenerateConfirm: {
|
regenerateConfirm: {
|
||||||
title: 'Regenerate API Key?',
|
title: 'Regenerate API Key?',
|
||||||
@@ -386,6 +472,7 @@ i18n
|
|||||||
auto_start: 'Auto Start with App',
|
auto_start: 'Auto Start with App',
|
||||||
auto_start_desc: 'Start proxy service when application launches',
|
auto_start_desc: 'Start proxy service when application launches',
|
||||||
local_access: 'Local network access:',
|
local_access: 'Local network access:',
|
||||||
|
select_ip: 'Select IP',
|
||||||
no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
|
no_token_warning: '⚠️ API key is not set. Access is open to everyone!',
|
||||||
show_key: 'Show',
|
show_key: 'Show',
|
||||||
hide_key: 'Hide',
|
hide_key: 'Hide',
|
||||||
@@ -403,9 +490,19 @@ i18n
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vi: {
|
||||||
|
translation: vi,
|
||||||
|
},
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
translation: {
|
translation: {
|
||||||
appName: 'Antigravity 管理器',
|
appName: 'Antigravity 管理器',
|
||||||
|
common: {
|
||||||
|
loading: '加载中...',
|
||||||
|
error: '错误',
|
||||||
|
unknown: '未知',
|
||||||
|
notAvailable: '不适用',
|
||||||
|
openMenu: '打开菜单',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
checking: '正在检查状态...',
|
checking: '正在检查状态...',
|
||||||
running: 'Antigravity 正在后台运行',
|
running: 'Antigravity 正在后台运行',
|
||||||
@@ -472,6 +569,7 @@ i18n
|
|||||||
english: '英语',
|
english: '英语',
|
||||||
chinese: '中文 (简体)',
|
chinese: '中文 (简体)',
|
||||||
russian: '俄语',
|
russian: '俄语',
|
||||||
|
vietnamese: '越南语',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: '关于',
|
title: '关于',
|
||||||
@@ -481,6 +579,15 @@ i18n
|
|||||||
platform: '平台',
|
platform: '平台',
|
||||||
license: '许可证',
|
license: '许可证',
|
||||||
openLogDir: '打开',
|
openLogDir: '打开',
|
||||||
|
toast: {
|
||||||
|
saved: {
|
||||||
|
title: '设置已保存',
|
||||||
|
description: '配置已更新。',
|
||||||
|
},
|
||||||
|
saveFailed: {
|
||||||
|
title: '保存设置失败',
|
||||||
|
},
|
||||||
|
},
|
||||||
account: {
|
account: {
|
||||||
title: '账号设置',
|
title: '账号设置',
|
||||||
description: '配置自动刷新和同步账号。',
|
description: '配置自动刷新和同步账号。',
|
||||||
@@ -504,6 +611,16 @@ i18n
|
|||||||
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
error_reporting_desc: '发送匿名错误报告以帮助我们改进应用。不收集个人数据。',
|
||||||
restart_note: '错误上报设置会在重启应用后生效。',
|
restart_note: '错误上报设置会在重启应用后生效。',
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
title: '通知',
|
||||||
|
description: '配置账号事件的桌面提醒。',
|
||||||
|
quotaAlert: '低配额提醒',
|
||||||
|
quotaAlertDesc: '当模型配额低于设定阈值时收到提醒',
|
||||||
|
quotaThreshold: '提醒阈值',
|
||||||
|
quotaThresholdDesc: '触发提醒的百分比阈值',
|
||||||
|
saveFailed: '保存通知设置失败',
|
||||||
|
thresholdSaveFailed: '保存阈值设置失败',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: '上游代理',
|
title: '上游代理',
|
||||||
description: '为发往 Google/Gemini API 的请求配置代理。',
|
description: '为发往 Google/Gemini API 的请求配置代理。',
|
||||||
@@ -518,7 +635,7 @@ i18n
|
|||||||
claudeKeyword: 'Claude 模型 (关键词)',
|
claudeKeyword: 'Claude 模型 (关键词)',
|
||||||
targetGemini: '目标 Gemini 模型',
|
targetGemini: '目标 Gemini 模型',
|
||||||
addPlaceholderKey: '例如 op-3',
|
addPlaceholderKey: '例如 op-3',
|
||||||
addPlaceholderValue: '例如 gemini-2.5-flash',
|
addPlaceholderValue: '例如 gemini-3-flash',
|
||||||
noMappings: '暂无自定义映射。',
|
noMappings: '暂无自定义映射。',
|
||||||
mapsTo: '映射到',
|
mapsTo: '映射到',
|
||||||
default: '默认',
|
default: '默认',
|
||||||
@@ -642,10 +759,13 @@ i18n
|
|||||||
twoCol: '2 列',
|
twoCol: '2 列',
|
||||||
threeCol: '3 列',
|
threeCol: '3 列',
|
||||||
list: '列表',
|
list: '列表',
|
||||||
|
compact: '紧凑',
|
||||||
},
|
},
|
||||||
authDialog: {
|
authDialog: {
|
||||||
title: '添加 Google 账号',
|
title: '添加 Google 账号',
|
||||||
description: '添加账号需要进行应用授权。',
|
description: '添加账号需要进行应用授权。',
|
||||||
|
oauthClient: 'OAuth 客户端',
|
||||||
|
oauthClientPlaceholder: '请选择 OAuth 客户端',
|
||||||
openLogin: '打开登录页面',
|
openLogin: '打开登录页面',
|
||||||
authCode: '授权码',
|
authCode: '授权码',
|
||||||
placeholder: '粘贴以 4/ 开头的代码...',
|
placeholder: '粘贴以 4/ 开头的代码...',
|
||||||
@@ -656,6 +776,9 @@ i18n
|
|||||||
active: '活跃',
|
active: '活跃',
|
||||||
use: '使用',
|
use: '使用',
|
||||||
rateLimited: '受限',
|
rateLimited: '受限',
|
||||||
|
validationRiskControlled: '风控 / 限流',
|
||||||
|
validationOAuthReauthRequired: '需要重新 OAuth 授权',
|
||||||
|
validationRequired: '需要校验',
|
||||||
left: '剩余',
|
left: '剩余',
|
||||||
used: '已用',
|
used: '已用',
|
||||||
unknown: '未知用户',
|
unknown: '未知用户',
|
||||||
@@ -672,6 +795,14 @@ i18n
|
|||||||
gemini3Ready: 'Gemini 3 就绪',
|
gemini3Ready: 'Gemini 3 就绪',
|
||||||
groupGoogleGemini: 'Google Gemini',
|
groupGoogleGemini: 'Google Gemini',
|
||||||
groupAnthropicClaude: 'Anthropic Claude',
|
groupAnthropicClaude: 'Anthropic Claude',
|
||||||
|
proxy: '代理',
|
||||||
|
proxyPlaceholder: '例如 http://127.0.0.1:7890',
|
||||||
|
proxySaved: '代理已保存',
|
||||||
|
noProxy: '无代理',
|
||||||
|
aiCredits: 'AI 积分',
|
||||||
|
aiCreditsValue: '{{amount}} 积分',
|
||||||
|
creditsExpiry: '{{date}} 过期',
|
||||||
|
modelVisibility: '模型可见性',
|
||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
title: '身份配置',
|
title: '身份配置',
|
||||||
@@ -731,6 +862,20 @@ i18n
|
|||||||
autoSwitchOn: '自动切换已启用',
|
autoSwitchOn: '自动切换已启用',
|
||||||
autoSwitchOff: '自动切换已禁用',
|
autoSwitchOff: '自动切换已禁用',
|
||||||
updateSettingsFailed: '更新设置失败',
|
updateSettingsFailed: '更新设置失败',
|
||||||
|
actionFailed: '操作失败',
|
||||||
|
startAuthFailed: '启动登录流程失败',
|
||||||
|
refreshCreditsAvailable: 'AI 积分:{{amount}}',
|
||||||
|
refreshCreditsUnavailable: '本次刷新没有可用的 AI 积分信息。',
|
||||||
|
batchRefreshSuccess: '成功刷新 {{count}} 个账号。',
|
||||||
|
batchRefreshPartial: {
|
||||||
|
title: '刷新已完成,但存在问题',
|
||||||
|
description: '已刷新 {{successful}} 个账号,{{failed}} 个失败。',
|
||||||
|
},
|
||||||
|
batchDeleteSuccess: '成功删除 {{count}} 个账号。',
|
||||||
|
batchDeletePartial: {
|
||||||
|
title: '删除已完成,但存在问题',
|
||||||
|
description: '已删除 {{successful}} 个账号,{{failed}} 个失败。',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
batch: {
|
batch: {
|
||||||
selected: '已选 {{count}} 项',
|
selected: '已选 {{count}} 项',
|
||||||
@@ -740,12 +885,42 @@ i18n
|
|||||||
clear: '取消选择',
|
clear: '取消选择',
|
||||||
confirmDelete: '确定要删除选中的 {{count}} 个账号吗?',
|
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: '导入中...',
|
||||||
|
fileTooLarge: '文件大小超过 5MB 限制',
|
||||||
|
invalidJson: 'JSON 文件格式无效',
|
||||||
|
readFileFailed: '读取文件失败',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'API 反代',
|
title: 'API 反代',
|
||||||
description: '管理本地 API 代理服务。',
|
description: '管理本地 API 代理服务。',
|
||||||
save: '保存设置',
|
save: '保存设置',
|
||||||
copy: '复制',
|
copy: '复制',
|
||||||
|
copied: '已复制!',
|
||||||
regenerate: '重新生成',
|
regenerate: '重新生成',
|
||||||
regenerateConfirm: {
|
regenerateConfirm: {
|
||||||
title: '重新生成 API 密钥?',
|
title: '重新生成 API 密钥?',
|
||||||
@@ -768,6 +943,7 @@ i18n
|
|||||||
auto_start: '随应用启动',
|
auto_start: '随应用启动',
|
||||||
auto_start_desc: '应用启动时自动启动代理服务',
|
auto_start_desc: '应用启动时自动启动代理服务',
|
||||||
local_access: '局域网访问:',
|
local_access: '局域网访问:',
|
||||||
|
select_ip: '选择 IP',
|
||||||
no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!',
|
no_token_warning: '⚠️ 未设置 API 密钥。服务对公网开放!',
|
||||||
show_key: '显示密钥',
|
show_key: '显示密钥',
|
||||||
hide_key: '隐藏密钥',
|
hide_key: '隐藏密钥',
|
||||||
@@ -788,6 +964,13 @@ i18n
|
|||||||
ru: {
|
ru: {
|
||||||
translation: {
|
translation: {
|
||||||
appName: 'Antigravity Manager',
|
appName: 'Antigravity Manager',
|
||||||
|
common: {
|
||||||
|
loading: 'Загрузка...',
|
||||||
|
error: 'Ошибка',
|
||||||
|
unknown: 'Неизвестно',
|
||||||
|
notAvailable: 'Н/Д',
|
||||||
|
openMenu: 'Открыть меню',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
checking: 'Проверка статуса...',
|
checking: 'Проверка статуса...',
|
||||||
running: 'Antigravity работает в фоне',
|
running: 'Antigravity работает в фоне',
|
||||||
@@ -855,6 +1038,7 @@ i18n
|
|||||||
english: 'English',
|
english: 'English',
|
||||||
chinese: '中文 (简体)',
|
chinese: '中文 (简体)',
|
||||||
russian: 'Русский',
|
russian: 'Русский',
|
||||||
|
vietnamese: 'Вьетнамский',
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: 'О программе',
|
title: 'О программе',
|
||||||
@@ -864,6 +1048,15 @@ i18n
|
|||||||
platform: 'Платформа',
|
platform: 'Платформа',
|
||||||
license: 'Лицензия',
|
license: 'Лицензия',
|
||||||
openLogDir: 'Открыть',
|
openLogDir: 'Открыть',
|
||||||
|
toast: {
|
||||||
|
saved: {
|
||||||
|
title: 'Настройки сохранены',
|
||||||
|
description: 'Конфигурация обновлена.',
|
||||||
|
},
|
||||||
|
saveFailed: {
|
||||||
|
title: 'Ошибка сохранения настроек',
|
||||||
|
},
|
||||||
|
},
|
||||||
account: {
|
account: {
|
||||||
title: 'Настройки аккаунта',
|
title: 'Настройки аккаунта',
|
||||||
description: 'Настройка автоматического обновления и синхронизации.',
|
description: 'Настройка автоматического обновления и синхронизации.',
|
||||||
@@ -889,6 +1082,17 @@ i18n
|
|||||||
restart_note:
|
restart_note:
|
||||||
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
'Изменения в отчетах об ошибках вступят в силу после перезапуска приложения.',
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
title: 'Уведомления',
|
||||||
|
description: 'Настройте уведомления о событиях аккаунтов.',
|
||||||
|
quotaAlert: 'Уведомления о низкой квоте',
|
||||||
|
quotaAlertDesc:
|
||||||
|
'Получайте уведомления, когда квота модели падает ниже установленного порога',
|
||||||
|
quotaThreshold: 'Порог уведомления',
|
||||||
|
quotaThresholdDesc: 'Процент, ниже которого срабатывает уведомление',
|
||||||
|
saveFailed: 'Не удалось сохранить настройки уведомлений',
|
||||||
|
thresholdSaveFailed: 'Не удалось сохранить порог уведомления',
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'Верхнеуровневый прокси',
|
title: 'Верхнеуровневый прокси',
|
||||||
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
description: 'Настроить прокси для исходящих запросов к Google/Gemini API.',
|
||||||
@@ -903,7 +1107,7 @@ i18n
|
|||||||
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
claudeKeyword: 'Модель Claude (Ключевое слово)',
|
||||||
targetGemini: 'Целевая модель Gemini',
|
targetGemini: 'Целевая модель Gemini',
|
||||||
addPlaceholderKey: 'например, op-3',
|
addPlaceholderKey: 'например, op-3',
|
||||||
addPlaceholderValue: 'например, gemini-2.5-flash',
|
addPlaceholderValue: 'например, gemini-3-flash',
|
||||||
noMappings: 'Нет пользовательских сопоставлений.',
|
noMappings: 'Нет пользовательских сопоставлений.',
|
||||||
mapsTo: 'Мапится в',
|
mapsTo: 'Мапится в',
|
||||||
default: 'По умолчанию',
|
default: 'По умолчанию',
|
||||||
@@ -1028,10 +1232,13 @@ i18n
|
|||||||
twoCol: '2 колонки',
|
twoCol: '2 колонки',
|
||||||
threeCol: '3 колонки',
|
threeCol: '3 колонки',
|
||||||
list: 'Список',
|
list: 'Список',
|
||||||
|
compact: 'Компактный',
|
||||||
},
|
},
|
||||||
authDialog: {
|
authDialog: {
|
||||||
title: 'Добавить Google Аккаунт',
|
title: 'Добавить Google Аккаунт',
|
||||||
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
||||||
|
oauthClient: 'OAuth клиент',
|
||||||
|
oauthClientPlaceholder: 'Выберите OAuth клиент',
|
||||||
openLogin: 'Открыть страницу входа',
|
openLogin: 'Открыть страницу входа',
|
||||||
authCode: 'Код авторизации',
|
authCode: 'Код авторизации',
|
||||||
placeholder: 'Вставьте код, начинающийся с 4/...',
|
placeholder: 'Вставьте код, начинающийся с 4/...',
|
||||||
@@ -1043,6 +1250,9 @@ i18n
|
|||||||
active: 'Активен',
|
active: 'Активен',
|
||||||
use: 'Использовать',
|
use: 'Использовать',
|
||||||
rateLimited: 'Лимит исчерпан',
|
rateLimited: 'Лимит исчерпан',
|
||||||
|
validationRiskControlled: 'Риск / Ограничение',
|
||||||
|
validationOAuthReauthRequired: 'Требуется повторный OAuth',
|
||||||
|
validationRequired: 'Требуется проверка',
|
||||||
left: 'ост.',
|
left: 'ост.',
|
||||||
used: 'Исп.',
|
used: 'Исп.',
|
||||||
unknown: 'Неизвестный',
|
unknown: 'Неизвестный',
|
||||||
@@ -1059,6 +1269,14 @@ i18n
|
|||||||
gemini3Ready: 'Gemini 3 готов',
|
gemini3Ready: 'Gemini 3 готов',
|
||||||
groupGoogleGemini: 'Google Gemini',
|
groupGoogleGemini: 'Google Gemini',
|
||||||
groupAnthropicClaude: 'Anthropic Claude',
|
groupAnthropicClaude: 'Anthropic Claude',
|
||||||
|
proxy: 'Прокси',
|
||||||
|
proxyPlaceholder: 'напр. http://127.0.0.1:7890',
|
||||||
|
proxySaved: 'Прокси сохранён',
|
||||||
|
noProxy: 'Без прокси',
|
||||||
|
aiCredits: 'AI Кредиты',
|
||||||
|
aiCreditsValue: '{{amount}} кредитов',
|
||||||
|
creditsExpiry: 'истекает {{date}}',
|
||||||
|
modelVisibility: 'Видимость моделей',
|
||||||
},
|
},
|
||||||
identity: {
|
identity: {
|
||||||
title: 'Профиль идентичности',
|
title: 'Профиль идентичности',
|
||||||
@@ -1118,6 +1336,20 @@ i18n
|
|||||||
autoSwitchOn: 'Авто-переключение включено',
|
autoSwitchOn: 'Авто-переключение включено',
|
||||||
autoSwitchOff: 'Авто-переключение выключено',
|
autoSwitchOff: 'Авто-переключение выключено',
|
||||||
updateSettingsFailed: 'Не удалось обновить настройки',
|
updateSettingsFailed: 'Не удалось обновить настройки',
|
||||||
|
actionFailed: 'Не удалось выполнить действие',
|
||||||
|
startAuthFailed: 'Не удалось запустить процесс входа',
|
||||||
|
refreshCreditsAvailable: 'AI кредиты: {{amount}}',
|
||||||
|
refreshCreditsUnavailable: 'AI кредиты недоступны для этого обновления.',
|
||||||
|
batchRefreshSuccess: 'Успешно обновлено {{count}} аккаунтов.',
|
||||||
|
batchRefreshPartial: {
|
||||||
|
title: 'Обновление завершено с проблемами',
|
||||||
|
description: 'Обновлено {{successful}} аккаунтов, {{failed}} с ошибкой.',
|
||||||
|
},
|
||||||
|
batchDeleteSuccess: 'Успешно удалено {{count}} аккаунтов.',
|
||||||
|
batchDeletePartial: {
|
||||||
|
title: 'Удаление завершено с проблемами',
|
||||||
|
description: 'Удалено {{successful}} аккаунтов, {{failed}} с ошибкой.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
batch: {
|
batch: {
|
||||||
selected: 'Выбрано {{count}}',
|
selected: 'Выбрано {{count}}',
|
||||||
@@ -1127,12 +1359,43 @@ i18n
|
|||||||
clear: 'Снять выделение',
|
clear: 'Снять выделение',
|
||||||
confirmDelete: 'Вы уверены, что хотите удалить {{count}} аккаунтов?',
|
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: 'Импорт...',
|
||||||
|
fileTooLarge: 'Размер файла превышает лимит в 5 МБ',
|
||||||
|
invalidJson: 'Некорректный формат JSON-файла',
|
||||||
|
readFileFailed: 'Не удалось прочитать файл',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
title: 'API Прокси',
|
title: 'API Прокси',
|
||||||
description: 'Управление локальным прокси-сервером API.',
|
description: 'Управление локальным прокси-сервером API.',
|
||||||
save: 'Сохранить',
|
save: 'Сохранить',
|
||||||
copy: 'Копировать',
|
copy: 'Копировать',
|
||||||
|
copied: 'Скопировано!',
|
||||||
regenerate: 'Перегенерировать',
|
regenerate: 'Перегенерировать',
|
||||||
regenerateConfirm: {
|
regenerateConfirm: {
|
||||||
title: 'Перегенерировать API ключ?',
|
title: 'Перегенерировать API ключ?',
|
||||||
@@ -1156,6 +1419,7 @@ i18n
|
|||||||
auto_start: 'Автозапуск',
|
auto_start: 'Автозапуск',
|
||||||
auto_start_desc: 'Запускать прокси при старте приложения',
|
auto_start_desc: 'Запускать прокси при старте приложения',
|
||||||
local_access: 'Доступ из локальной сети:',
|
local_access: 'Доступ из локальной сети:',
|
||||||
|
select_ip: 'Выбрать IP',
|
||||||
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
|
no_token_warning: '⚠️ API ключ не установлен. Доступ открыт всем!',
|
||||||
show_key: 'Показать',
|
show_key: 'Показать',
|
||||||
hide_key: 'Скрыть',
|
hide_key: 'Скрыть',
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
const vi = {
|
||||||
|
appName: 'Antigravity Manager',
|
||||||
|
common: {
|
||||||
|
loading: 'Đang tải...',
|
||||||
|
error: 'Lỗi',
|
||||||
|
unknown: 'Không rõ',
|
||||||
|
notAvailable: 'Không có',
|
||||||
|
openMenu: 'Mở menu',
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
checking: 'Đang kiểm tra trạng thái...',
|
||||||
|
running: 'Antigravity đang chạy nền',
|
||||||
|
stopped: 'Dịch vụ Antigravity đã dừng',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
stop: 'Dừng',
|
||||||
|
start: 'Bắt đầu',
|
||||||
|
switch: 'Chuyển',
|
||||||
|
deleteBackup: 'Xóa bản sao lưu',
|
||||||
|
backupCurrent: 'Sao lưu tài khoản hiện tại',
|
||||||
|
retry: 'Thử lại',
|
||||||
|
openLogs: 'Mở thư mục log',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
generic: 'Đã xảy ra lỗi không mong đợi.',
|
||||||
|
keychainUnavailable: 'Keychain không khả dụng.',
|
||||||
|
keychainHint: {
|
||||||
|
translocation:
|
||||||
|
'Đã phát hiện macOS App Translocation. Hãy chuyển ứng dụng vào /Applications rồi mở lại.',
|
||||||
|
keychainDenied:
|
||||||
|
'Quyền truy cập Keychain đã bị từ chối. Ứng dụng có thể chưa được ký; hãy xem README để biết cách tự ký.',
|
||||||
|
signNotarize: 'Hãy ưu tiên dùng bản dựng đã được ký và công chứng khi có thể.',
|
||||||
|
},
|
||||||
|
dataMigrationFailed: 'Không thể giải mã dữ liệu tài khoản cũ.',
|
||||||
|
dataMigrationHint: {
|
||||||
|
relogin: 'Hãy đăng nhập lại hoặc thêm lại tài khoản của bạn.',
|
||||||
|
clearData: 'Nếu lỗi vẫn tiếp diễn, hãy xóa dữ liệu tài khoản cục bộ rồi đăng nhập lại.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
accounts: 'Tài khoản',
|
||||||
|
proxy: 'API Proxy',
|
||||||
|
settings: 'Cài đặt',
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
current: 'Hiện tại',
|
||||||
|
lastUsed: 'Đã dùng {{time}}',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
title: 'Tài khoản',
|
||||||
|
description: 'Quản lý các tài khoản Antigravity Google Gemini.',
|
||||||
|
noBackups: {
|
||||||
|
title: 'Chưa tìm thấy bản sao lưu',
|
||||||
|
description: 'Hãy tạo bản sao lưu cho tài khoản Antigravity hiện tại để bắt đầu.',
|
||||||
|
action: 'Sao lưu tài khoản hiện tại',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
title: 'Cài đặt',
|
||||||
|
description: 'Quản lý tùy chọn ứng dụng.',
|
||||||
|
general: 'Chung',
|
||||||
|
connection: 'Kết nối',
|
||||||
|
models: 'Mô hình',
|
||||||
|
appearance: {
|
||||||
|
title: 'Giao diện',
|
||||||
|
description: 'Tùy chỉnh giao diện Antigravity Manager trên thiết bị của bạn.',
|
||||||
|
},
|
||||||
|
darkMode: 'Chế độ tối',
|
||||||
|
darkModeDescription: 'Bật chế độ tối để dễ nhìn hơn vào ban đêm.',
|
||||||
|
language: {
|
||||||
|
title: 'Ngôn ngữ',
|
||||||
|
description: 'Chọn ngôn ngữ bạn muốn sử dụng.',
|
||||||
|
english: 'Tiếng Anh',
|
||||||
|
chinese: 'Tiếng Trung Giản thể',
|
||||||
|
russian: 'Tiếng Nga',
|
||||||
|
vietnamese: 'Tiếng Việt',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: 'Thông tin',
|
||||||
|
description: 'Thông tin ứng dụng.',
|
||||||
|
},
|
||||||
|
version: 'Phiên bản',
|
||||||
|
platform: 'Nền tảng',
|
||||||
|
license: 'Giấy phép',
|
||||||
|
openLogDir: 'Mở',
|
||||||
|
toast: {
|
||||||
|
saved: {
|
||||||
|
title: 'Đã lưu cài đặt',
|
||||||
|
description: 'Cấu hình của bạn đã được cập nhật.',
|
||||||
|
},
|
||||||
|
saveFailed: {
|
||||||
|
title: 'Không thể lưu cài đặt',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
title: 'Cài đặt tài khoản',
|
||||||
|
description: 'Cấu hình tự động làm mới quota và đồng bộ.',
|
||||||
|
auto_refresh: 'Tự động làm mới quota',
|
||||||
|
auto_refresh_desc: 'Định kỳ cập nhật thông tin quota cho tất cả tài khoản',
|
||||||
|
auto_sync: 'Tự động đồng bộ tài khoản hiện tại',
|
||||||
|
auto_sync_desc: 'Định kỳ đồng bộ thông tin tài khoản đang hoạt động',
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
title: 'Khởi động',
|
||||||
|
description: 'Điều khiển hành vi khởi động cùng hệ thống.',
|
||||||
|
auto_startup: 'Khởi động cùng hệ thống',
|
||||||
|
auto_startup_desc: 'Chạy khi đăng nhập và ẩn vào khay hệ thống',
|
||||||
|
macos_hint:
|
||||||
|
'macOS cần bản ký ứng dụng để Login Items hoạt động. Nếu tự động khởi động thất bại, hãy ký ứng dụng hoặc bật thủ công trong System Settings.',
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: 'Quyền riêng tư',
|
||||||
|
description: 'Quản lý cách dữ liệu được sử dụng để cải thiện ứng dụng.',
|
||||||
|
error_reporting: 'Báo cáo lỗi',
|
||||||
|
error_reporting_desc:
|
||||||
|
'Gửi báo cáo lỗi ẩn danh để giúp cải thiện ứng dụng. Không thu thập dữ liệu cá nhân.',
|
||||||
|
restart_note: 'Thay đổi báo cáo lỗi sẽ có hiệu lực sau khi khởi động lại ứng dụng.',
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
title: 'Thông báo',
|
||||||
|
description: 'Cấu hình cảnh báo trên desktop cho các sự kiện tài khoản.',
|
||||||
|
quotaAlert: 'Cảnh báo quota thấp',
|
||||||
|
quotaAlertDesc: 'Nhận thông báo khi quota của model giảm xuống dưới ngưỡng đã đặt',
|
||||||
|
quotaThreshold: 'Ngưỡng cảnh báo',
|
||||||
|
quotaThresholdDesc: 'Phần trăm dùng để kích hoạt cảnh báo',
|
||||||
|
saveFailed: 'Không thể lưu cài đặt thông báo',
|
||||||
|
thresholdSaveFailed: 'Không thể lưu ngưỡng cảnh báo',
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
title: 'Proxy trung gian',
|
||||||
|
description: 'Cấu hình proxy cho các request đi ra Google/Gemini APIs.',
|
||||||
|
enable: 'Bật proxy trung gian',
|
||||||
|
url: 'Địa chỉ proxy',
|
||||||
|
timeout: 'Thời gian chờ request (giây)',
|
||||||
|
},
|
||||||
|
modelMapping: {
|
||||||
|
title: 'Ánh xạ mô hình',
|
||||||
|
description:
|
||||||
|
'Ánh xạ các model Claude Code sang model Antigravity để tối ưu chi phí và tốc độ.',
|
||||||
|
claudeKeyword: 'Từ khóa model Claude',
|
||||||
|
targetGemini: 'Model Gemini đích',
|
||||||
|
addPlaceholderKey: 'ví dụ: op-3',
|
||||||
|
addPlaceholderValue: 'ví dụ: gemini-3-flash',
|
||||||
|
noMappings: 'Chưa có ánh xạ tùy chỉnh.',
|
||||||
|
mapsTo: 'Ánh xạ tới',
|
||||||
|
default: 'Mặc định',
|
||||||
|
restoreDefaults: 'Khôi phục mặc định',
|
||||||
|
},
|
||||||
|
modelVisibility: {
|
||||||
|
title: 'Hiển thị mô hình',
|
||||||
|
description:
|
||||||
|
'Điều khiển model nào được hiện trong thẻ tài khoản. Model bị ẩn sẽ không xuất hiện ở phần quota.',
|
||||||
|
searchPlaceholder: 'Tìm mô hình...',
|
||||||
|
showAll: 'Hiện tất cả',
|
||||||
|
hideAll: 'Ẩn tất cả',
|
||||||
|
reset: 'Đặt lại mặc định',
|
||||||
|
save: 'Lưu thay đổi',
|
||||||
|
noModels: 'Không tìm thấy mô hình',
|
||||||
|
modelsShown: 'Đang hiện {{visible}} / {{total}} mô hình',
|
||||||
|
quotaManagement: 'Quản lý quota',
|
||||||
|
hidden: 'Đã ẩn',
|
||||||
|
noModelsFound: 'Không tìm thấy mô hình',
|
||||||
|
totalModels: 'Tổng',
|
||||||
|
visibleModels: 'Đang hiện',
|
||||||
|
hiddenModels: 'Đã ẩn',
|
||||||
|
saving: 'Đang lưu...',
|
||||||
|
},
|
||||||
|
providerGroupings: {
|
||||||
|
title: 'Nhóm theo nhà cung cấp',
|
||||||
|
description: 'Nhóm model theo nhà cung cấp để dễ quản lý hơn',
|
||||||
|
enabled: 'Bật nhóm theo nhà cung cấp',
|
||||||
|
models: '{{count}} mô hình',
|
||||||
|
avgLabel: 'tb',
|
||||||
|
resetLabel: 'reset',
|
||||||
|
overall: 'Tổng thể',
|
||||||
|
healthy: 'Tốt',
|
||||||
|
degraded: 'Giảm',
|
||||||
|
limited: 'Giới hạn',
|
||||||
|
critical: 'Nghiêm trọng',
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
title: 'Ví dụ sử dụng',
|
||||||
|
description: 'Ví dụ lệnh để gọi local API proxy.',
|
||||||
|
curl: 'cURL',
|
||||||
|
python: 'Python',
|
||||||
|
copy: 'Sao chép',
|
||||||
|
copied: 'Đã sao chép!',
|
||||||
|
openai_protocol: 'Giao thức OpenAI',
|
||||||
|
anthropic_protocol: 'Giao thức Anthropic',
|
||||||
|
openai_tools: 'Cursor, Windsurf, NextChat',
|
||||||
|
anthropic_tools: 'Claude Code CLI',
|
||||||
|
flash: 'Nhanh',
|
||||||
|
pro: 'Pro',
|
||||||
|
flash_preview: 'Xem trước',
|
||||||
|
pro_high: 'Tốt nhất',
|
||||||
|
sonnet: 'Thinking',
|
||||||
|
opus: 'Opus',
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
title: 'Dịch vụ API Proxy',
|
||||||
|
description: 'Điều khiển local API proxy server.',
|
||||||
|
status_running: 'Đang chạy',
|
||||||
|
status_stopped: 'Đã dừng',
|
||||||
|
action_start: 'Bắt đầu dịch vụ',
|
||||||
|
action_stop: 'Dừng dịch vụ',
|
||||||
|
accounts_info: '{{count}} tài khoản khả dụng',
|
||||||
|
port: 'Cổng lắng nghe',
|
||||||
|
port_hint: 'Mặc định 8045, cần khởi động lại để áp dụng',
|
||||||
|
timeout: 'Thời gian chờ request',
|
||||||
|
timeout_hint: 'Mặc định 120 giây, khoảng 30-600 giây',
|
||||||
|
api_key: 'API Key',
|
||||||
|
regenerate_key: 'Tạo lại',
|
||||||
|
regenerateConfirm: {
|
||||||
|
title: 'Tạo lại API Key?',
|
||||||
|
description:
|
||||||
|
'API key hiện tại sẽ hết hiệu lực ngay lập tức. Các ứng dụng đang dùng key cũ sẽ ngừng hoạt động.',
|
||||||
|
cancel: 'Hủy',
|
||||||
|
confirm: 'Tạo lại',
|
||||||
|
},
|
||||||
|
key_warning: 'Hãy giữ kín API key của bạn. Không chia sẻ với người khác.',
|
||||||
|
auto_start: 'Tự động chạy cùng app',
|
||||||
|
auto_start_desc: 'Khởi động proxy khi ứng dụng mở lên',
|
||||||
|
},
|
||||||
|
proxy_tab: 'Proxy',
|
||||||
|
save: 'Lưu cài đặt',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
backupSuccess: {
|
||||||
|
title: 'Thành công',
|
||||||
|
description: 'Đã tạo bản sao lưu tài khoản.',
|
||||||
|
},
|
||||||
|
backupError: {
|
||||||
|
title: 'Lỗi',
|
||||||
|
description: 'Không thể tạo bản sao lưu: {{error}}',
|
||||||
|
},
|
||||||
|
switchSuccess: {
|
||||||
|
title: 'Thành công',
|
||||||
|
description: 'Chuyển tài khoản thành công.',
|
||||||
|
},
|
||||||
|
switchError: {
|
||||||
|
title: 'Lỗi',
|
||||||
|
description: 'Không thể chuyển tài khoản: {{error}}',
|
||||||
|
},
|
||||||
|
deleteSuccess: {
|
||||||
|
title: 'Thành công',
|
||||||
|
description: 'Đã xóa bản sao lưu tài khoản.',
|
||||||
|
},
|
||||||
|
deleteError: {
|
||||||
|
title: 'Lỗi',
|
||||||
|
description: 'Không thể xóa bản sao lưu: {{error}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cloud: {
|
||||||
|
title: 'Tài khoản',
|
||||||
|
description: 'Quản lý pool tài khoản Google Gemini của bạn.',
|
||||||
|
autoSwitch: 'Tự động chuyển',
|
||||||
|
providerGroupings: 'Nhóm theo nhà cung cấp',
|
||||||
|
addAccount: 'Thêm tài khoản',
|
||||||
|
syncFromIDE: 'Đồng bộ từ IDE',
|
||||||
|
checkQuota: 'Kiểm tra quota ngay',
|
||||||
|
polling: 'Đã kích hoạt polling',
|
||||||
|
globalQuota: 'Quota tổng',
|
||||||
|
layout: {
|
||||||
|
auto: 'Tự động',
|
||||||
|
twoCol: '2 cột',
|
||||||
|
threeCol: '3 cột',
|
||||||
|
list: 'Danh sách',
|
||||||
|
compact: 'Thu gọn',
|
||||||
|
},
|
||||||
|
authDialog: {
|
||||||
|
title: 'Thêm tài khoản Google',
|
||||||
|
description: 'Bạn cần cấp quyền ứng dụng để thêm tài khoản.',
|
||||||
|
oauthClient: 'OAuth Client',
|
||||||
|
oauthClientPlaceholder: 'Chọn OAuth client',
|
||||||
|
openLogin: 'Mở trang đăng nhập',
|
||||||
|
authCode: 'Mã ủy quyền',
|
||||||
|
placeholder: 'Dán mã bắt đầu bằng 4/...',
|
||||||
|
instruction:
|
||||||
|
'Ứng dụng sẽ mở trình duyệt mặc định để đăng nhập Google. Sao chép mã từ trang localhost và dán vào đây.',
|
||||||
|
verify: 'Xác thực và thêm',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
active: 'Đang hoạt động',
|
||||||
|
use: 'Sử dụng',
|
||||||
|
rateLimited: 'Bị giới hạn tốc độ',
|
||||||
|
validationRiskControlled: 'Rủi ro / Bị giới hạn',
|
||||||
|
validationOAuthReauthRequired: 'Cần xác thực lại OAuth',
|
||||||
|
validationRequired: 'Cần xác minh',
|
||||||
|
left: 'còn lại',
|
||||||
|
used: 'Đã dùng',
|
||||||
|
unknown: 'Người dùng không rõ',
|
||||||
|
actions: 'Hành động',
|
||||||
|
useAccount: 'Dùng tài khoản này',
|
||||||
|
identityProfile: 'Hồ sơ định danh',
|
||||||
|
refresh: 'Làm mới quota',
|
||||||
|
delete: 'Xóa tài khoản',
|
||||||
|
noQuota: 'Chưa có dữ liệu quota',
|
||||||
|
rateLimitedQuota: 'Bị giới hạn',
|
||||||
|
resetPrefix: 'reset',
|
||||||
|
resetTime: 'Thời điểm reset',
|
||||||
|
resetUnknown: 'Không rõ',
|
||||||
|
gemini3Ready: 'Sẵn sàng cho Gemini 3',
|
||||||
|
groupGoogleGemini: 'Google Gemini',
|
||||||
|
groupAnthropicClaude: 'Anthropic Claude',
|
||||||
|
proxy: 'Proxy',
|
||||||
|
proxyPlaceholder: 'ví dụ: http://127.0.0.1:7890',
|
||||||
|
proxySaved: 'Đã lưu proxy',
|
||||||
|
noProxy: 'Không dùng proxy',
|
||||||
|
aiCredits: 'AI Credits',
|
||||||
|
aiCreditsValue: '{{amount}} credits',
|
||||||
|
creditsExpiry: 'hết hạn {{date}}',
|
||||||
|
modelVisibility: 'Hiển thị model',
|
||||||
|
},
|
||||||
|
identity: {
|
||||||
|
title: 'Hồ sơ định danh',
|
||||||
|
loading: 'Đang tải...',
|
||||||
|
generateAndBind: 'Tạo và gán',
|
||||||
|
captureAndBind: 'Lấy và gán hồ sơ hiện tại',
|
||||||
|
restoreOriginal: 'Khôi phục baseline',
|
||||||
|
openFolder: 'Mở thư mục lưu hồ sơ',
|
||||||
|
previewTitle: 'Xem trước hồ sơ đã tạo',
|
||||||
|
confirm: 'Xác nhận',
|
||||||
|
cancel: 'Hủy',
|
||||||
|
close: 'Đóng',
|
||||||
|
currentStorage: 'Hồ sơ runtime hiện tại',
|
||||||
|
accountBinding: 'Hồ sơ đang gán với tài khoản',
|
||||||
|
history: 'Lịch sử hồ sơ',
|
||||||
|
noHistory: 'Chưa có lịch sử hồ sơ',
|
||||||
|
current: 'Đang dùng',
|
||||||
|
restore: 'Khôi phục',
|
||||||
|
generateSuccess: 'Đã tạo và gán hồ sơ',
|
||||||
|
captureSuccess: 'Đã lấy và gán hồ sơ hiện tại',
|
||||||
|
restoreOriginalSuccess: 'Đã khôi phục baseline',
|
||||||
|
restoreVersionSuccess: 'Đã khôi phục bản lịch sử',
|
||||||
|
deleteVersionSuccess: 'Đã xóa bản lịch sử',
|
||||||
|
openFolderSuccess: 'Đã mở thư mục lưu hồ sơ',
|
||||||
|
baseline: 'Hồ sơ baseline',
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
noAccounts: 'Chưa có tài khoản cloud nào được thêm.',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
loadFailed: 'Không thể tải danh sách tài khoản cloud.',
|
||||||
|
},
|
||||||
|
toast: {
|
||||||
|
syncSuccess: {
|
||||||
|
title: 'Đồng bộ thành công',
|
||||||
|
description: 'Đã nhập {{email}} từ IDE.',
|
||||||
|
},
|
||||||
|
syncFailed: {
|
||||||
|
title: 'Đồng bộ thất bại',
|
||||||
|
description: 'Không tìm thấy tài khoản đang hoạt động trong database của IDE.',
|
||||||
|
},
|
||||||
|
addSuccess: 'Đã thêm tài khoản thành công!',
|
||||||
|
addFailed: {
|
||||||
|
title: 'Không thể thêm tài khoản',
|
||||||
|
},
|
||||||
|
quotaRefreshed: 'Đã làm mới quota',
|
||||||
|
refreshFailed: 'Không thể làm mới quota',
|
||||||
|
pollFailed: 'Không thể polling quota của tất cả tài khoản',
|
||||||
|
switched: {
|
||||||
|
title: 'Đã chuyển tài khoản!',
|
||||||
|
description: 'Đang khởi động lại Antigravity...',
|
||||||
|
},
|
||||||
|
switchFailed: 'Không thể chuyển tài khoản',
|
||||||
|
deleted: 'Đã xóa tài khoản',
|
||||||
|
deleteFailed: 'Không thể xóa tài khoản',
|
||||||
|
deleteConfirm: 'Bạn có chắc chắn muốn xóa tài khoản này không?',
|
||||||
|
autoSwitchOn: 'Đã bật tự động chuyển',
|
||||||
|
autoSwitchOff: 'Đã tắt tự động chuyển',
|
||||||
|
updateSettingsFailed: 'Không thể cập nhật cài đặt',
|
||||||
|
actionFailed: 'Không thể thực hiện thao tác',
|
||||||
|
startAuthFailed: 'Không thể bắt đầu luồng đăng nhập',
|
||||||
|
refreshCreditsAvailable: 'AI credits: {{amount}}',
|
||||||
|
refreshCreditsUnavailable: 'Không có thông tin AI credits cho lần làm mới này.',
|
||||||
|
batchRefreshSuccess: 'Đã làm mới thành công {{count}} tài khoản.',
|
||||||
|
batchRefreshPartial: {
|
||||||
|
title: 'Làm mới hoàn tất nhưng có lỗi',
|
||||||
|
description: 'Đã làm mới {{successful}} tài khoản, {{failed}} tài khoản thất bại.',
|
||||||
|
},
|
||||||
|
batchDeleteSuccess: 'Đã xóa thành công {{count}} tài khoản.',
|
||||||
|
batchDeletePartial: {
|
||||||
|
title: 'Xóa hoàn tất nhưng có lỗi',
|
||||||
|
description: 'Đã xóa {{successful}} tài khoản, {{failed}} tài khoản thất bại.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
batch: {
|
||||||
|
selected: 'Đã chọn {{count}}',
|
||||||
|
delete: 'Xóa mục đã chọn',
|
||||||
|
refresh: 'Làm mới mục đã chọn',
|
||||||
|
selectAll: 'Chọn tất cả',
|
||||||
|
clear: 'Bỏ chọn',
|
||||||
|
confirmDelete: 'Bạn có chắc chắn muốn xóa {{count}} tài khoản không?',
|
||||||
|
},
|
||||||
|
sort: {
|
||||||
|
recentlyUsed: 'Dùng gần đây',
|
||||||
|
quotaOverall: 'Quota tổng',
|
||||||
|
quotaClaude: 'Quota Claude',
|
||||||
|
quotaPro3: 'Quota Pro3',
|
||||||
|
quotaFlash: 'Quota Flash',
|
||||||
|
},
|
||||||
|
exportImport: {
|
||||||
|
export: 'Xuất',
|
||||||
|
import: 'Nhập',
|
||||||
|
exportTitle: 'Xuất tài khoản',
|
||||||
|
exportDesc: 'Chọn có bao gồm token xác thực trong tệp xuất hay không.',
|
||||||
|
includeTokens: 'Bao gồm token (kém an toàn hơn)',
|
||||||
|
stripTokens: 'Loại bỏ token (an toàn hơn khi chia sẻ)',
|
||||||
|
exportSuccess: 'Đã xuất tài khoản thành công',
|
||||||
|
importTitle: 'Nhập tài khoản',
|
||||||
|
importDesc: 'Chọn tệp JSON đã được xuất trước đó.',
|
||||||
|
importStrategy: 'Chiến lược nhập',
|
||||||
|
strategyMerge: 'Gộp - Cập nhật tài khoản cũ, thêm tài khoản mới',
|
||||||
|
strategyOverwrite: 'Ghi đè - Thay thế toàn bộ dữ liệu hiện có',
|
||||||
|
strategySkip: 'Bỏ qua - Chỉ thêm tài khoản mới',
|
||||||
|
importSuccess: 'Đã nhập {{imported}}, cập nhật {{updated}}, bỏ qua {{skipped}}',
|
||||||
|
importErrors: 'Đã nhập xong với {{count}} lỗi',
|
||||||
|
selectFile: 'Chọn tệp',
|
||||||
|
importing: 'Đang nhập...',
|
||||||
|
fileTooLarge: 'Kích thước tệp vượt quá giới hạn 5MB',
|
||||||
|
invalidJson: 'Định dạng tệp JSON không hợp lệ',
|
||||||
|
readFileFailed: 'Không thể đọc tệp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
title: 'API Proxy',
|
||||||
|
description: 'Quản lý local API proxy service.',
|
||||||
|
save: 'Lưu cài đặt',
|
||||||
|
copy: 'Sao chép',
|
||||||
|
copied: 'Đã sao chép!',
|
||||||
|
regenerate: 'Tạo lại',
|
||||||
|
regenerateConfirm: {
|
||||||
|
title: 'Tạo lại API Key?',
|
||||||
|
description:
|
||||||
|
'API key hiện tại sẽ hết hiệu lực ngay lập tức. Các ứng dụng đang dùng key cũ sẽ ngừng hoạt động.',
|
||||||
|
cancel: 'Hủy',
|
||||||
|
confirm: 'Tạo lại',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
title: 'Trạng thái dịch vụ',
|
||||||
|
description: 'Điều khiển local API proxy server.',
|
||||||
|
running: 'Đang chạy',
|
||||||
|
stopped: 'Đã dừng',
|
||||||
|
start: 'Bắt đầu dịch vụ',
|
||||||
|
stop: 'Dừng dịch vụ',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
port: 'Cổng lắng nghe',
|
||||||
|
timeout: 'Thời gian chờ request',
|
||||||
|
api_key: 'API Key',
|
||||||
|
auto_start: 'Tự động chạy cùng app',
|
||||||
|
auto_start_desc: 'Khởi động proxy khi ứng dụng mở lên',
|
||||||
|
local_access: 'Truy cập trong mạng nội bộ:',
|
||||||
|
select_ip: 'Chọn IP',
|
||||||
|
no_token_warning: 'API key chưa được đặt. Bất kỳ ai trong mạng đều có thể truy cập!',
|
||||||
|
show_key: 'Hiện',
|
||||||
|
hide_key: 'Ẩn',
|
||||||
|
},
|
||||||
|
mapping: {
|
||||||
|
title: 'Ánh xạ mô hình',
|
||||||
|
description: 'Ánh xạ model Claude sang Gemini để định tuyến.',
|
||||||
|
maps_to: 'Ánh xạ tới',
|
||||||
|
restore: 'Khôi phục mặc định',
|
||||||
|
},
|
||||||
|
examples: {
|
||||||
|
title: 'Ví dụ sử dụng',
|
||||||
|
description: 'Ví dụ lệnh để gọi local API proxy.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default vi;
|
||||||
+54
@@ -4,6 +4,7 @@ import type { MessageBoxOptions } from 'electron';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import squirrelStartup from 'electron-squirrel-startup';
|
import squirrelStartup from 'electron-squirrel-startup';
|
||||||
|
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
import { ipcMain } from 'electron/main';
|
import { ipcMain } from 'electron/main';
|
||||||
import { ipcContext } from '@/ipc/context';
|
import { ipcContext } from '@/ipc/context';
|
||||||
@@ -51,12 +52,53 @@ ipcMain.on(IPC_CHANNELS.CHANGE_LANGUAGE, (event, lang) => {
|
|||||||
|
|
||||||
app.disableHardwareAcceleration();
|
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) {
|
if (squirrelStartup) {
|
||||||
app.quit();
|
app.quit();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
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
|
// 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.
|
// 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();
|
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
if (!gotSingleInstanceLock) {
|
if (!gotSingleInstanceLock) {
|
||||||
app.quit();
|
app.quit();
|
||||||
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
logger.info('Second instance detected, focusing existing window');
|
logger.info('Second instance detected, focusing existing window');
|
||||||
@@ -418,6 +467,11 @@ app
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
logger.info('Startup: Auto-Switch enabled, starting monitor...');
|
logger.info('Startup: Auto-Switch enabled, starting monitor...');
|
||||||
CloudMonitorService.start();
|
CloudMonitorService.start();
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
'Startup: Auto-Switch disabled, running one-time quota and AI credits sync...',
|
||||||
|
);
|
||||||
|
await CloudMonitorService.poll();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Startup: Failed to initialize services', e);
|
logger.error('Startup: Failed to initialize services', e);
|
||||||
|
|||||||
+139
-145
@@ -6,7 +6,7 @@ import { createFileRoute } from '@tanstack/react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
import { ProxyConfig } from '@/types/config';
|
import { ProxyConfig } from '@/types/config';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -43,6 +43,59 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} 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() {
|
function ProxyPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config, isLoading, saveConfig } = useAppConfig();
|
const { config, isLoading, saveConfig } = useAppConfig();
|
||||||
@@ -97,14 +150,14 @@ function ProxyPage() {
|
|||||||
} else {
|
} else {
|
||||||
setProxyConfig(config.proxy);
|
setProxyConfig(config.proxy);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// If status check fails, just use config value
|
// If status check fails, just use config value
|
||||||
setProxyConfig(config.proxy);
|
setProxyConfig(config.proxy);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
syncServerStatus();
|
syncServerStatus();
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config, saveConfig]);
|
||||||
|
|
||||||
// Helper to update proxyConfig and auto-save
|
// Helper to update proxyConfig and auto-save
|
||||||
const updateProxyConfig = async (newProxyConfig: ProxyConfig) => {
|
const updateProxyConfig = async (newProxyConfig: ProxyConfig) => {
|
||||||
@@ -115,24 +168,27 @@ function ProxyPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ===== Usage Examples State =====
|
// ===== Usage Examples State =====
|
||||||
const [selectedProtocol, setSelectedProtocol] = useState<'openai' | 'anthropic'>('openai');
|
const [selectedProtocol, setSelectedProtocol] = useState<ProxyProtocol>('openai');
|
||||||
const [activeModelTab, setActiveModelTab] = useState('gemini-2.5-flash');
|
const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high');
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
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
|
// Computed values for examples
|
||||||
const apiKey = proxyConfig?.api_key || 'YOUR_API_KEY';
|
const apiKey = proxyConfig?.api_key || 'YOUR_API_KEY';
|
||||||
const baseUrl = `http://localhost:${proxyConfig?.port || 8045}`;
|
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) => {
|
const copyToClipboard = (text: string, type: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
setCopied(type);
|
setCopied(type);
|
||||||
@@ -201,16 +257,14 @@ print(response.choices[0].message.content)`;
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title', 'API Proxy')}</h2>
|
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title')}</h2>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">{t('proxy.description')}</p>
|
||||||
{t('proxy.description', 'Manage the local API proxy service.')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Local Access Info Banner */}
|
{/* Local Access Info Banner */}
|
||||||
{proxyConfig?.enabled && (
|
{proxyConfig?.enabled && (
|
||||||
<div className="mt-4 flex flex-col gap-2 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-200">
|
<div className="mt-4 flex flex-col gap-2 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-200">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-semibold">{t('proxy.config.local_access', 'Local Access:')}</div>
|
<div className="font-semibold">{t('proxy.config.local_access')}</div>
|
||||||
<code className="rounded bg-blue-100 px-1.5 py-0.5 font-mono select-all dark:bg-blue-900/50">
|
<code className="rounded bg-blue-100 px-1.5 py-0.5 font-mono select-all dark:bg-blue-900/50">
|
||||||
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
|
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
|
||||||
</code>
|
</code>
|
||||||
@@ -218,7 +272,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{localIps && localIps.length > 1 && (
|
{localIps && localIps.length > 1 && (
|
||||||
<Select value={selectedIp} onValueChange={setSelectedIp}>
|
<Select value={selectedIp} onValueChange={setSelectedIp}>
|
||||||
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs">
|
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs">
|
||||||
<SelectValue placeholder="Select IP" />
|
<SelectValue placeholder={t('proxy.config.select_ip')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{localIps.map((ip) => (
|
{localIps.map((ip) => (
|
||||||
@@ -232,10 +286,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
{!proxyConfig.api_key && (
|
{!proxyConfig.api_key && (
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
|
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||||
{t(
|
{t('proxy.config.no_token_warning')}
|
||||||
'proxy.config.no_token_warning',
|
|
||||||
'⚠️ No API Key set. Service is open to the public network!',
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,19 +298,15 @@ print(response.choices[0].message.content)`;
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>{t('proxy.service.title', 'Service Status')}</CardTitle>
|
<CardTitle>{t('proxy.service.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('proxy.service.description')}</CardDescription>
|
||||||
{t('proxy.service.description', 'Control the local API proxy server.')}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`}
|
className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{proxyConfig.enabled
|
{proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')}
|
||||||
? t('proxy.service.running', 'Running')
|
|
||||||
: t('proxy.service.stopped', 'Stopped')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,16 +327,14 @@ print(response.choices[0].message.content)`;
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{proxyConfig.enabled
|
{proxyConfig.enabled ? t('proxy.service.stop') : t('proxy.service.start')}
|
||||||
? t('proxy.service.stop', 'Stop Service')
|
|
||||||
: t('proxy.service.start', 'Start Service')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Port & Timeout Configuration */}
|
{/* Port & Timeout Configuration */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="gateway-port">{t('proxy.config.port', 'Listen Port')}</Label>
|
<Label htmlFor="gateway-port">{t('proxy.config.port')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="gateway-port"
|
id="gateway-port"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -301,9 +346,7 @@ print(response.choices[0].message.content)`;
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="gateway-timeout">
|
<Label htmlFor="gateway-timeout">{t('proxy.config.timeout')}</Label>
|
||||||
{t('proxy.config.timeout', 'Request Timeout')}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="gateway-timeout"
|
id="gateway-timeout"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -320,7 +363,7 @@ print(response.choices[0].message.content)`;
|
|||||||
|
|
||||||
{/* API Key */}
|
{/* API Key */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('proxy.config.api_key', 'API Key')}</Label>
|
<Label>{t('proxy.config.api_key')}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -349,28 +392,21 @@ print(response.choices[0].message.content)`;
|
|||||||
onClick={() => navigator.clipboard.writeText(proxyConfig.api_key || '')}
|
onClick={() => navigator.clipboard.writeText(proxyConfig.api_key || '')}
|
||||||
>
|
>
|
||||||
<Copy size={14} className="mr-1" />
|
<Copy size={14} className="mr-1" />
|
||||||
{t('proxy.copy', 'Copy')}
|
{t('proxy.copy')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
|
||||||
{t('proxy.regenerate', 'Regenerate')}
|
{t('proxy.regenerate')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}>
|
<Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
|
||||||
{t('proxy.regenerateConfirm.title', 'Regenerate API Key?')}
|
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{t(
|
|
||||||
'proxy.regenerateConfirm.description',
|
|
||||||
'This will invalidate the current API key immediately. Any applications using the old key will stop working.',
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
|
||||||
{t('proxy.regenerateConfirm.cancel', 'Cancel')}
|
{t('proxy.regenerateConfirm.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -381,7 +417,7 @@ print(response.choices[0].message.content)`;
|
|||||||
setIsRegenerateDialogOpen(false);
|
setIsRegenerateDialogOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('proxy.regenerateConfirm.confirm', 'Regenerate')}
|
{t('proxy.regenerateConfirm.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -391,10 +427,8 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* Auto Start Toggle */}
|
{/* Auto Start Toggle */}
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('proxy.config.auto_start', 'Auto Start with App')}</Label>
|
<Label>{t('proxy.config.auto_start')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t('proxy.config.auto_start_desc')}</p>
|
||||||
{t('proxy.config.auto_start_desc', 'Start proxy service when application launches')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={proxyConfig.auto_start}
|
checked={proxyConfig.auto_start}
|
||||||
@@ -409,36 +443,32 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* Model Mapping Card */}
|
{/* Model Mapping Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.mapping.title', 'Model Mapping')}</CardTitle>
|
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
|
||||||
{t('proxy.mapping.description', 'Map Claude models to Gemini models for routing.')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
{/* Sonnet 4.5 Card */}
|
{/* 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="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="mb-2 flex items-center gap-2">
|
||||||
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div>
|
<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">
|
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
Claude Sonnet 4.5
|
Claude Sonnet 4.6 (Thinking)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
||||||
{t('proxy.mapping.maps_to', 'Maps to')}
|
{t('proxy.mapping.maps_to')}
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={resolveAnthropicMappingValue(
|
||||||
proxyConfig.anthropic_mapping['claude-sonnet-4-5-20250929'] ||
|
proxyConfig.anthropic_mapping,
|
||||||
'claude-sonnet-4-5-thinking'
|
['claude-sonnet-4-6-20260219', 'claude-sonnet-4-5-20250929'],
|
||||||
}
|
'claude-sonnet-4-6-thinking',
|
||||||
|
)}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateProxyConfig({
|
updateAnthropicMapping({
|
||||||
...proxyConfig,
|
'claude-sonnet-4-6-20260219': value,
|
||||||
anthropic_mapping: {
|
'claude-sonnet-4-5-20250929': value,
|
||||||
...proxyConfig.anthropic_mapping,
|
|
||||||
'claude-sonnet-4-5-20250929': value,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -446,34 +476,36 @@ print(response.choices[0].message.content)`;
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="claude-sonnet-4-5-thinking">
|
{ANTHROPIC_ROUTE_OPTIONS.map((option) => (
|
||||||
claude-sonnet-4-5-thinking
|
<SelectItem key={option} value={option}>
|
||||||
</SelectItem>
|
{option}
|
||||||
<SelectItem value="gemini-2.5-flash">gemini-2.5-flash</SelectItem>
|
</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>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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="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="mb-2 flex items-center gap-2">
|
||||||
<div className="h-2 w-2 animate-pulse rounded-full bg-purple-500"></div>
|
<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">
|
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||||
Claude Opus 4.5
|
Claude Opus 4.6 (Thinking)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
|
||||||
{t('proxy.mapping.maps_to', 'Maps to')}
|
{t('proxy.mapping.maps_to')}
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<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) =>
|
onValueChange={(value) =>
|
||||||
updateProxyConfig({
|
updateAnthropicMapping({
|
||||||
...proxyConfig,
|
'claude-opus-4-6-20260201': value,
|
||||||
anthropic_mapping: { ...proxyConfig.anthropic_mapping, opus: value },
|
opus: value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -481,45 +513,11 @@ print(response.choices[0].message.content)`;
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="claude-opus-4-5-thinking">claude-opus-4-5-thinking</SelectItem>
|
{ANTHROPIC_ROUTE_OPTIONS.map((option) => (
|
||||||
<SelectItem value="gemini-2.5-pro">gemini-2.5-pro</SelectItem>
|
<SelectItem key={option} value={option}>
|
||||||
<SelectItem value="gemini-3-pro-high">gemini-3-pro-high</SelectItem>
|
{option}
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</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>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,15 +530,11 @@ print(response.choices[0].message.content)`;
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateProxyConfig({
|
updateProxyConfig({
|
||||||
...proxyConfig,
|
...proxyConfig,
|
||||||
anthropic_mapping: {
|
anthropic_mapping: { ...DEFAULT_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',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('proxy.mapping.restore', 'Restore Defaults')}
|
{t('proxy.mapping.restore')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -551,11 +545,9 @@ print(response.choices[0].message.content)`;
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Code size={20} />
|
<Code size={20} />
|
||||||
{t('proxy.examples.title', 'Usage Examples')}
|
{t('proxy.examples.title')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('proxy.examples.description')}</CardDescription>
|
||||||
{t('proxy.examples.description', 'Example commands to call the local API proxy.')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Protocol Selector Cards */}
|
{/* Protocol Selector Cards */}
|
||||||
@@ -570,7 +562,7 @@ print(response.choices[0].message.content)`;
|
|||||||
className={`h-2 w-2 rounded-full ${selectedProtocol === 'openai' ? 'animate-pulse bg-blue-500' : 'bg-blue-400'}`}
|
className={`h-2 w-2 rounded-full ${selectedProtocol === 'openai' ? 'animate-pulse bg-blue-500' : 'bg-blue-400'}`}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-sm font-bold text-blue-700 dark:text-blue-400">
|
<span className="text-sm font-bold text-blue-700 dark:text-blue-400">
|
||||||
OpenAI Protocol
|
{t('settings.examples.openai_protocol')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 rounded border border-blue-200/50 bg-white/60 px-3 py-2 dark:border-blue-700/30 dark:bg-gray-800/40">
|
<div className="mb-2 rounded border border-blue-200/50 bg-white/60 px-3 py-2 dark:border-blue-700/30 dark:bg-gray-800/40">
|
||||||
@@ -579,7 +571,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
💡 Cursor, Windsurf, NextChat
|
{t('settings.examples.openai_tools')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -593,7 +585,7 @@ print(response.choices[0].message.content)`;
|
|||||||
className={`h-2 w-2 rounded-full ${selectedProtocol === 'anthropic' ? 'animate-pulse bg-purple-500' : 'bg-purple-400'}`}
|
className={`h-2 w-2 rounded-full ${selectedProtocol === 'anthropic' ? 'animate-pulse bg-purple-500' : 'bg-purple-400'}`}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-sm font-bold text-purple-700 dark:text-purple-400">
|
<span className="text-sm font-bold text-purple-700 dark:text-purple-400">
|
||||||
Anthropic Protocol
|
{t('settings.examples.anthropic_protocol')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 rounded border border-purple-200/50 bg-white/60 px-3 py-2 dark:border-purple-700/30 dark:bg-gray-800/40">
|
<div className="mb-2 rounded border border-purple-200/50 bg-white/60 px-3 py-2 dark:border-purple-700/30 dark:bg-gray-800/40">
|
||||||
@@ -601,13 +593,15 @@ print(response.choices[0].message.content)`;
|
|||||||
POST /v1/messages
|
POST /v1/messages
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">💡 Claude Code CLI</p>
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{t('settings.examples.anthropic_tools')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Tabs */}
|
{/* Model Tabs */}
|
||||||
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex flex-wrap gap-1 border-b border-gray-200 dark:border-gray-700">
|
||||||
{models.map((model) => (
|
{EXAMPLE_MODELS.map((model) => (
|
||||||
<button
|
<button
|
||||||
key={model.id}
|
key={model.id}
|
||||||
onClick={() => setActiveModelTab(model.id)}
|
onClick={() => setActiveModelTab(model.id)}
|
||||||
@@ -631,7 +625,7 @@ print(response.choices[0].message.content)`;
|
|||||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
|
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
{copied === 'curl' ? <CheckCircle size={14} /> : <Copy size={14} />}
|
{copied === 'curl' ? <CheckCircle size={14} /> : <Copy size={14} />}
|
||||||
{copied === 'curl' ? 'Copied!' : 'Copy'}
|
{copied === 'curl' ? t('proxy.copied') : t('proxy.copy')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
||||||
@@ -651,7 +645,7 @@ print(response.choices[0].message.content)`;
|
|||||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
|
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
{copied === 'python' ? <CheckCircle size={14} /> : <Copy size={14} />}
|
{copied === 'python' ? <CheckCircle size={14} /> : <Copy size={14} />}
|
||||||
{copied === 'python' ? 'Copied!' : 'Copy'}
|
{copied === 'python' ? t('proxy.copied') : t('proxy.copy')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
||||||
|
|||||||
+94
-58
@@ -17,6 +17,7 @@ import { getAppVersion, getPlatform } from '@/actions/app';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { setAppLanguage } from '@/actions/language';
|
import { setAppLanguage } from '@/actions/language';
|
||||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { Loader2, FolderOpen } from 'lucide-react';
|
import { Loader2, FolderOpen } from 'lucide-react';
|
||||||
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
import { ModelVisibilitySettings } from '@/components/ModelVisibilitySettings';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -27,6 +28,7 @@ function SettingsPage() {
|
|||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { config, isLoading, saveConfig } = useAppConfig();
|
const { config, isLoading, saveConfig } = useAppConfig();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Local state for configuration editing
|
// Local state for configuration editing
|
||||||
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
const [proxyConfig, setProxyConfig] = useState<ProxyConfig | undefined>(undefined);
|
||||||
@@ -82,9 +84,9 @@ function SettingsPage() {
|
|||||||
|
|
||||||
<Tabs defaultValue="general" className="w-full">
|
<Tabs defaultValue="general" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="general">{t('settings.general', 'General')}</TabsTrigger>
|
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
||||||
<TabsTrigger value="models">{t('settings.models', 'Models')}</TabsTrigger>
|
<TabsTrigger value="models">{t('settings.models')}</TabsTrigger>
|
||||||
<TabsTrigger value="proxy">{t('settings.proxy_tab', 'Proxy')}</TabsTrigger>
|
<TabsTrigger value="proxy">{t('settings.proxy_tab')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* --- GENERAL TAB --- */}
|
{/* --- GENERAL TAB --- */}
|
||||||
@@ -128,6 +130,7 @@ function SettingsPage() {
|
|||||||
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
|
<SelectItem value="en">{t('settings.language.english')}</SelectItem>
|
||||||
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
|
<SelectItem value="zh-CN">{t('settings.language.chinese')}</SelectItem>
|
||||||
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
|
<SelectItem value="ru">{t('settings.language.russian')}</SelectItem>
|
||||||
|
<SelectItem value="vi">{t('settings.language.vietnamese')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,22 +140,15 @@ function SettingsPage() {
|
|||||||
{/* Account Settings Card */}
|
{/* Account Settings Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('settings.account.title', 'Account Settings')}</CardTitle>
|
<CardTitle>{t('settings.account.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('settings.account.description')}</CardDescription>
|
||||||
{t('settings.account.description', 'Configure automatic account refresh and sync.')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* Auto Refresh Quota */}
|
{/* Auto Refresh Quota */}
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.account.auto_refresh', 'Auto Refresh Quota')}</Label>
|
<Label>{t('settings.account.auto_refresh')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p>
|
||||||
{t(
|
|
||||||
'settings.account.auto_refresh_desc',
|
|
||||||
'Periodically refresh quota info for all accounts',
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config?.auto_refresh || false}
|
checked={config?.auto_refresh || false}
|
||||||
@@ -167,13 +163,8 @@ function SettingsPage() {
|
|||||||
{/* Auto Sync Account */}
|
{/* Auto Sync Account */}
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.account.auto_sync', 'Auto Sync Current Account')}</Label>
|
<Label>{t('settings.account.auto_sync')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p>
|
||||||
{t(
|
|
||||||
'settings.account.auto_sync_desc',
|
|
||||||
'Periodically sync active account information',
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config?.auto_sync || false}
|
checked={config?.auto_sync || false}
|
||||||
@@ -190,23 +181,15 @@ function SettingsPage() {
|
|||||||
{isAutoStartSupported && (
|
{isAutoStartSupported && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('settings.startup.title', 'Startup')}</CardTitle>
|
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
||||||
{t(
|
|
||||||
'settings.startup.description',
|
|
||||||
'Control application launch behavior at system startup.',
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.startup.auto_startup', 'Start with system')}</Label>
|
<Label>{t('settings.startup.auto_startup')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{t(
|
{t('settings.startup.auto_startup_desc')}
|
||||||
'settings.startup.auto_startup_desc',
|
|
||||||
'Launch at sign-in and keep the app in the system tray',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -220,10 +203,7 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{isMac && (
|
{isMac && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{t(
|
{t('settings.startup.macos_hint')}
|
||||||
'settings.startup.macos_hint',
|
|
||||||
'macOS requires a signed app for Login Items to work. If auto-start fails, please sign the app or enable it manually in System Settings.',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -238,10 +218,10 @@ function SettingsPage() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
<div className="text-muted-foreground">{t('settings.version')}</div>
|
<div className="text-muted-foreground">{t('settings.version')}</div>
|
||||||
<div className="font-medium">{appVersion || 'Unknown'}</div>
|
<div className="font-medium">{appVersion || t('common.unknown')}</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground">{t('settings.platform')}</div>
|
<div className="text-muted-foreground">{t('settings.platform')}</div>
|
||||||
<div className="font-medium capitalize">{platform || 'Unknown'}</div>
|
<div className="font-medium capitalize">{platform || t('common.unknown')}</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground">{t('settings.license')}</div>
|
<div className="text-muted-foreground">{t('settings.license')}</div>
|
||||||
<div className="font-medium">CC BY-NC-SA 4.0</div>
|
<div className="font-medium">CC BY-NC-SA 4.0</div>
|
||||||
@@ -252,7 +232,7 @@ function SettingsPage() {
|
|||||||
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
<span>{t('settings.openLogDir', 'Open')}</span>
|
<span>{t('settings.openLogDir')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -261,23 +241,15 @@ function SettingsPage() {
|
|||||||
{/* Privacy & Error Reporting Card */}
|
{/* Privacy & Error Reporting Card */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('settings.privacy.title', 'Privacy')}</CardTitle>
|
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
||||||
{t(
|
|
||||||
'settings.privacy.description',
|
|
||||||
'Control how your data is used to improve the application.',
|
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.privacy.error_reporting', 'Error Reporting')}</Label>
|
<Label>{t('settings.privacy.error_reporting')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{t(
|
{t('settings.privacy.error_reporting_desc')}
|
||||||
'settings.privacy.error_reporting_desc',
|
|
||||||
'Send anonymous error reports to help us improve the app. No personal data is collected.',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -289,12 +261,76 @@ function SettingsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">{t('settings.privacy.restart_note')}</p>
|
||||||
{t(
|
</CardContent>
|
||||||
'settings.privacy.restart_note',
|
</Card>
|
||||||
'Changes to error reporting will take effect after restarting the application.',
|
|
||||||
)}
|
{/* Notifications Card */}
|
||||||
</p>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
||||||
|
<CardDescription>{t('settings.notifications.description')}</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')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t('settings.notifications.quotaAlertDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config?.quota_alert_enabled || false}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (config) {
|
||||||
|
try {
|
||||||
|
await saveConfig({ ...config, quota_alert_enabled: checked });
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: t('common.error'),
|
||||||
|
description: t('settings.notifications.saveFailed'),
|
||||||
|
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')}</Label>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{t('settings.notifications.quotaThresholdDesc')}
|
||||||
|
</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 {
|
||||||
|
toast({
|
||||||
|
title: t('common.error'),
|
||||||
|
description: t('settings.notifications.thresholdSaveFailed'),
|
||||||
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { isEmpty, isString } from 'lodash-es';
|
||||||
|
|
||||||
export type RequestHeaderValue = string | string[] | undefined;
|
export type RequestHeaderValue = string | string[] | undefined;
|
||||||
|
|
||||||
export type RequestHeaders = Record<string, RequestHeaderValue>;
|
export type RequestHeaders = Record<string, RequestHeaderValue>;
|
||||||
|
|
||||||
export function hasConfiguredApiKey(apiKey: string | undefined): apiKey is string {
|
export function hasConfiguredApiKey(apiKey: string | undefined): apiKey is string {
|
||||||
return typeof apiKey === 'string' && apiKey.trim() !== '';
|
return isString(apiKey) && !isEmpty(apiKey.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractApiKeyToken(headers: RequestHeaders): string | null {
|
export function extractApiKeyToken(headers: RequestHeaders): string | null {
|
||||||
@@ -29,16 +31,16 @@ export function extractApiKeyToken(headers: RequestHeaders): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readHeaderValue(headerValue: RequestHeaderValue): string | null {
|
function readHeaderValue(headerValue: RequestHeaderValue): string | null {
|
||||||
if (typeof headerValue === 'string') {
|
if (isString(headerValue)) {
|
||||||
const trimmedValue = headerValue.trim();
|
const trimmedValue = headerValue.trim();
|
||||||
return trimmedValue !== '' ? trimmedValue : null;
|
return !isEmpty(trimmedValue) ? trimmedValue : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(headerValue)) {
|
if (Array.isArray(headerValue)) {
|
||||||
for (const value of headerValue) {
|
for (const value of headerValue) {
|
||||||
if (typeof value === 'string') {
|
if (isString(value)) {
|
||||||
const trimmedValue = value.trim();
|
const trimmedValue = value.trim();
|
||||||
if (trimmedValue !== '') {
|
if (!isEmpty(trimmedValue)) {
|
||||||
return trimmedValue;
|
return trimmedValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import axios, { AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import axios, { AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
import { isNil } from 'lodash-es';
|
import { isEmpty, isFunction, isNil, isObjectLike, isString } from 'lodash-es';
|
||||||
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces';
|
||||||
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
|
import { GeminiInternalRequest } from '../../../../lib/antigravity/types';
|
||||||
import { getServerConfig } from '../../../server-config';
|
import { getServerConfig } from '../../../server-config';
|
||||||
|
import { resolveRequestUserAgent } from '../request-user-agent';
|
||||||
|
import { UpstreamRequestError } from './upstream-error';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GeminiClient {
|
export class GeminiClient {
|
||||||
private readonly logger = new Logger(GeminiClient.name);
|
private readonly logger = new Logger(GeminiClient.name);
|
||||||
private readonly defaultRequestUserAgent = 'antigravity/1.11.9 windows/amd64';
|
|
||||||
// Default to v1beta for most features
|
// Default to v1beta for most features
|
||||||
private readonly baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
private readonly baseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
||||||
private readonly defaultInternalBaseUrls = [
|
private readonly defaultInternalBaseUrls = [
|
||||||
@@ -23,7 +24,7 @@ export class GeminiClient {
|
|||||||
upstreamProxyUrl?: string,
|
upstreamProxyUrl?: string,
|
||||||
): Promise<NodeJS.ReadableStream> {
|
): Promise<NodeJS.ReadableStream> {
|
||||||
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
|
const url = `${this.baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
|
||||||
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(url, content, {
|
const response = await axios.post(url, content, {
|
||||||
@@ -39,7 +40,14 @@ export class GeminiClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
this.logger.error(`Gemini stream request failed: ${error.message}`);
|
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);
|
this.throwAsCleanError(error);
|
||||||
}
|
}
|
||||||
@@ -52,7 +60,7 @@ export class GeminiClient {
|
|||||||
upstreamProxyUrl?: string,
|
upstreamProxyUrl?: string,
|
||||||
): Promise<GeminiResponse> {
|
): Promise<GeminiResponse> {
|
||||||
const url = `${this.baseUrl}/models/${model}:generateContent`;
|
const url = `${this.baseUrl}/models/${model}:generateContent`;
|
||||||
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<GeminiResponse>(url, content, {
|
const response = await axios.post<GeminiResponse>(url, content, {
|
||||||
@@ -69,7 +77,14 @@ export class GeminiClient {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Gemini request failed: ${error.message} - ${this.safeStringify(error.response?.data)}`,
|
`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);
|
this.throwAsCleanError(error);
|
||||||
}
|
}
|
||||||
@@ -83,7 +98,7 @@ export class GeminiClient {
|
|||||||
upstreamProxyUrl?: string,
|
upstreamProxyUrl?: string,
|
||||||
extraHeaders?: Record<string, string>,
|
extraHeaders?: Record<string, string>,
|
||||||
): Promise<NodeJS.ReadableStream> {
|
): Promise<NodeJS.ReadableStream> {
|
||||||
const response = await this.requestWithEndpointFailover<NodeJS.ReadableStream>(
|
const response = await this.executeRequestWithEndpointFailover<NodeJS.ReadableStream>(
|
||||||
':streamGenerateContent?alt=sse',
|
':streamGenerateContent?alt=sse',
|
||||||
body,
|
body,
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -104,7 +119,7 @@ export class GeminiClient {
|
|||||||
upstreamProxyUrl?: string,
|
upstreamProxyUrl?: string,
|
||||||
extraHeaders?: Record<string, string>,
|
extraHeaders?: Record<string, string>,
|
||||||
): Promise<GeminiResponse> {
|
): Promise<GeminiResponse> {
|
||||||
const response = await this.requestWithEndpointFailover<
|
const response = await this.executeRequestWithEndpointFailover<
|
||||||
GeminiResponse | { response: GeminiResponse }
|
GeminiResponse | { response: GeminiResponse }
|
||||||
>(
|
>(
|
||||||
':generateContent',
|
':generateContent',
|
||||||
@@ -116,10 +131,10 @@ export class GeminiClient {
|
|||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
const payload = response.data;
|
const payload = response.data;
|
||||||
if (payload && typeof payload === 'object' && 'response' in payload) {
|
if (isObjectLike(payload) && 'response' in payload) {
|
||||||
return payload.response;
|
return (payload as { response: GeminiResponse }).response;
|
||||||
}
|
}
|
||||||
return payload;
|
return payload as GeminiResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInternalBaseUrls(): string[] {
|
private getInternalBaseUrls(): string[] {
|
||||||
@@ -143,7 +158,7 @@ export class GeminiClient {
|
|||||||
return Math.max(1, timeoutSeconds) * 1000;
|
return Math.max(1, timeoutSeconds) * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldSwitchEndpointOnError(error: unknown): boolean {
|
private shouldFailoverToNextEndpoint(error: unknown): boolean {
|
||||||
if (!axios.isAxiosError(error)) {
|
if (!axios.isAxiosError(error)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -162,7 +177,7 @@ export class GeminiClient {
|
|||||||
return status === 408 || status === 429 || status >= 500;
|
return status === 408 || status === 429 || status >= 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requestWithEndpointFailover<T>(
|
private async executeRequestWithEndpointFailover<T>(
|
||||||
path: string,
|
path: string,
|
||||||
body: GeminiInternalRequest,
|
body: GeminiInternalRequest,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
@@ -173,8 +188,8 @@ export class GeminiClient {
|
|||||||
): Promise<AxiosResponse<T>> {
|
): Promise<AxiosResponse<T>> {
|
||||||
const baseUrls = this.getInternalBaseUrls();
|
const baseUrls = this.getInternalBaseUrls();
|
||||||
const timeout = this.getInternalTimeoutMs();
|
const timeout = this.getInternalTimeoutMs();
|
||||||
const requestUserAgent = process.env.PROXY_REQUEST_USER_AGENT ?? this.defaultRequestUserAgent;
|
const requestUserAgent = await resolveRequestUserAgent();
|
||||||
const axiosProxy = this.resolveAxiosProxy(upstreamProxyUrl);
|
const axiosProxy = this.resolveUpstreamAxiosProxy(upstreamProxyUrl);
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (let index = 0; index < baseUrls.length; index++) {
|
for (let index = 0; index < baseUrls.length; index++) {
|
||||||
@@ -197,8 +212,8 @@ export class GeminiClient {
|
|||||||
lastError = error;
|
lastError = error;
|
||||||
const hasNextEndpoint = index < baseUrls.length - 1;
|
const hasNextEndpoint = index < baseUrls.length - 1;
|
||||||
|
|
||||||
if (!hasNextEndpoint || !this.shouldSwitchEndpointOnError(error)) {
|
if (!hasNextEndpoint || !this.shouldFailoverToNextEndpoint(error)) {
|
||||||
await this.handleAxiosError(error, operation);
|
await this.throwUpstreamRequestError(error, operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(
|
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`);
|
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)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const responseData = error.response?.data;
|
const responseData = error.response?.data;
|
||||||
const upstreamMessage = await this.extractAxiosErrorMessage(responseData);
|
const upstreamMessage = await this.extractAxiosErrorMessage(responseData);
|
||||||
@@ -222,7 +237,14 @@ export class GeminiClient {
|
|||||||
responseData,
|
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);
|
this.throwAsCleanError(error);
|
||||||
}
|
}
|
||||||
@@ -233,7 +255,7 @@ export class GeminiClient {
|
|||||||
return fromObject;
|
return fromObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof responseData === 'string') {
|
if (isString(responseData)) {
|
||||||
return this.extractAxiosErrorMessageFromText(responseData);
|
return this.extractAxiosErrorMessageFromText(responseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,20 +272,20 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractAxiosErrorMessageFromObject(responseData: unknown): string | null {
|
private extractAxiosErrorMessageFromObject(responseData: unknown): string | null {
|
||||||
if (!responseData || typeof responseData !== 'object' || this.isReadableStream(responseData)) {
|
if (!isObjectLike(responseData) || this.isReadableStream(responseData)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorRecord = (responseData as { error?: unknown }).error;
|
const errorRecord = (responseData as { error?: unknown }).error;
|
||||||
if (errorRecord && typeof errorRecord === 'object') {
|
if (isObjectLike(errorRecord)) {
|
||||||
const message = (errorRecord as { message?: unknown }).message;
|
const message = (errorRecord as { message?: unknown }).message;
|
||||||
if (typeof message === 'string' && message.trim() !== '') {
|
if (isString(message) && !isEmpty(message.trim())) {
|
||||||
return message.trim();
|
return message.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = (responseData as { message?: unknown }).message;
|
const message = (responseData as { message?: unknown }).message;
|
||||||
if (typeof message === 'string' && message.trim() !== '') {
|
if (isString(message) && !isEmpty(message.trim())) {
|
||||||
return message.trim();
|
return message.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +333,7 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isReadableStream(value: unknown): value is NodeJS.ReadableStream {
|
private isReadableStream(value: unknown): value is NodeJS.ReadableStream {
|
||||||
return (
|
return isObjectLike(value) && isFunction((value as { pipe?: unknown }).pipe);
|
||||||
Boolean(value) &&
|
|
||||||
typeof value === 'object' &&
|
|
||||||
typeof (value as { pipe?: unknown }).pipe === 'function'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readStreamAsText(stream: NodeJS.ReadableStream): Promise<string | null> {
|
private async readStreamAsText(stream: NodeJS.ReadableStream): Promise<string | null> {
|
||||||
@@ -346,7 +364,9 @@ export class GeminiClient {
|
|||||||
return this.safeStringify(responseData);
|
return this.safeStringify(responseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveAxiosProxy(upstreamProxyUrl?: string): AxiosProxyConfig | false | undefined {
|
private resolveUpstreamAxiosProxy(
|
||||||
|
upstreamProxyUrl?: string,
|
||||||
|
): AxiosProxyConfig | false | undefined {
|
||||||
const config = getServerConfig();
|
const config = getServerConfig();
|
||||||
const configuredProxyUrl =
|
const configuredProxyUrl =
|
||||||
upstreamProxyUrl ||
|
upstreamProxyUrl ||
|
||||||
@@ -382,6 +402,24 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractRetryAfterHeader(headers: unknown): string | undefined {
|
||||||
|
if (!isObjectLike(headers)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfter = (headers as Record<string, unknown>)['retry-after'];
|
||||||
|
if (isString(retryAfter) && !isEmpty(retryAfter.trim())) {
|
||||||
|
return retryAfter.trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(retryAfter) && retryAfter.length > 0) {
|
||||||
|
const first = retryAfter[0];
|
||||||
|
if (isString(first) && !isEmpty(first.trim())) {
|
||||||
|
return first.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private throwAsCleanError(error: unknown): never {
|
private throwAsCleanError(error: unknown): never {
|
||||||
// Re-throw as clean Error to avoid circular reference issues.
|
// Re-throw as clean Error to avoid circular reference issues.
|
||||||
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
|
throw error instanceof Error ? new Error(error.message) : new Error(String(error));
|
||||||
@@ -397,7 +435,7 @@ export class GeminiClient {
|
|||||||
try {
|
try {
|
||||||
const seen = new WeakSet();
|
const seen = new WeakSet();
|
||||||
return JSON.stringify(obj, (key, value) => {
|
return JSON.stringify(obj, (key, value) => {
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (isObjectLike(value)) {
|
||||||
if (seen.has(value)) return '[Circular]';
|
if (seen.has(value)) return '[Circular]';
|
||||||
seen.add(value);
|
seen.add(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { isNumber, isObjectLike, isString } from 'lodash-es';
|
||||||
|
|
||||||
|
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 && !isNumber(params.status)) {
|
||||||
|
throw new TypeError('status must be a number');
|
||||||
|
}
|
||||||
|
if (params.headers && !isObjectLike(params.headers)) {
|
||||||
|
throw new TypeError('headers must be an object');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = params.status;
|
||||||
|
this.headers = params.headers;
|
||||||
|
|
||||||
|
// Sanitize and limit body size
|
||||||
|
if (isString(params.body)) {
|
||||||
|
const sanitized = params.body.replace(/<[^>]*>?/gm, '');
|
||||||
|
this.body = sanitized.substring(0, 1000);
|
||||||
|
} else {
|
||||||
|
this.body = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty, isFunction, isNumber, isString } from 'lodash-es';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { ProxyGuard } from './proxy.guard';
|
import { ProxyGuard } from './proxy.guard';
|
||||||
@@ -18,6 +19,7 @@ import { ProxyService } from './proxy.service';
|
|||||||
import { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
|
import { GeminiRequest, GeminiResponse } from './interfaces/request-interfaces';
|
||||||
import { getServerConfig } from '../../server-config';
|
import { getServerConfig } from '../../server-config';
|
||||||
import { getAllDynamicModels } from '../../../lib/antigravity/ModelMapping';
|
import { getAllDynamicModels } from '../../../lib/antigravity/ModelMapping';
|
||||||
|
import { TokenManagerService } from './token-manager.service';
|
||||||
|
|
||||||
type GeminiModelMetadata = {
|
type GeminiModelMetadata = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,7 +37,10 @@ type GeminiModelMetadata = {
|
|||||||
@Controller('v1beta')
|
@Controller('v1beta')
|
||||||
@UseGuards(ProxyGuard)
|
@UseGuards(ProxyGuard)
|
||||||
export class GeminiController {
|
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')
|
@Get('models')
|
||||||
listModels(@Res() res: FastifyReply) {
|
listModels(@Res() res: FastifyReply) {
|
||||||
@@ -70,7 +75,7 @@ export class GeminiController {
|
|||||||
@Body() body: GeminiRequest,
|
@Body() body: GeminiRequest,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const parsed = this.parseCombinedModelAction(modelAction);
|
const parsed = this.parseModelActionToken(modelAction);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
res.status(HttpStatus.BAD_REQUEST).send({
|
res.status(HttpStatus.BAD_REQUEST).send({
|
||||||
error: {
|
error: {
|
||||||
@@ -82,7 +87,7 @@ export class GeminiController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dispatchAction(parsed.model, parsed.action, body, res);
|
await this.handleModelActionDispatch(parsed.model, parsed.action, body, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('models/:model/countTokens')
|
@Post('models/:model/countTokens')
|
||||||
@@ -91,10 +96,10 @@ export class GeminiController {
|
|||||||
@Body() body: GeminiRequest,
|
@Body() body: GeminiRequest,
|
||||||
@Res() res: FastifyReply,
|
@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,
|
model: string,
|
||||||
action: string,
|
action: string,
|
||||||
body: GeminiRequest,
|
body: GeminiRequest,
|
||||||
@@ -111,14 +116,14 @@ export class GeminiController {
|
|||||||
if (action === 'streamGenerateContent') {
|
if (action === 'streamGenerateContent') {
|
||||||
const stream = await this.proxyService.handleGeminiStreamGenerateContent(model, body);
|
const stream = await this.proxyService.handleGeminiStreamGenerateContent(model, body);
|
||||||
if (stream instanceof Observable) {
|
if (stream instanceof Observable) {
|
||||||
this.sendObservableSse(res, stream);
|
this.writeObservableSseResponse(res, stream);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'generateContent') {
|
if (action === 'generateContent') {
|
||||||
const result = await this.proxyService.handleGeminiGenerateContent(model, body);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +146,7 @@ export class GeminiController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseCombinedModelAction(modelAction: string): {
|
private parseModelActionToken(modelAction: string): {
|
||||||
model: string;
|
model: string;
|
||||||
action: string;
|
action: string;
|
||||||
} | null {
|
} | null {
|
||||||
@@ -165,7 +170,10 @@ export class GeminiController {
|
|||||||
|
|
||||||
private buildGeminiModelList(): GeminiModelMetadata[] {
|
private buildGeminiModelList(): GeminiModelMetadata[] {
|
||||||
const config = getServerConfig();
|
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}`));
|
return dynamicModelIds.map((id) => this.toGeminiModelMetadata(`models/${id}`));
|
||||||
}
|
}
|
||||||
@@ -186,11 +194,11 @@ export class GeminiController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
|
private buildNormalizedGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
|
||||||
const candidates = (response.candidates ?? []).map((candidate, index) => ({
|
const candidates = (response.candidates ?? []).map((candidate, index) => ({
|
||||||
content: candidate.content,
|
content: candidate.content,
|
||||||
finishReason: candidate.finishReason,
|
finishReason: candidate.finishReason,
|
||||||
index: typeof candidate.index === 'number' ? candidate.index : index,
|
index: isNumber(candidate.index) ? candidate.index : index,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const normalized: GeminiResponse = {
|
const normalized: GeminiResponse = {
|
||||||
@@ -235,11 +243,11 @@ export class GeminiController {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendObservableSse(res: FastifyReply, stream: Observable<unknown>): void {
|
private writeObservableSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
|
||||||
if (
|
if (
|
||||||
!res.raw ||
|
!res.raw ||
|
||||||
typeof res.raw.writeHead !== 'function' ||
|
!isFunction(res.raw.writeHead) ||
|
||||||
typeof res.raw.write !== 'function'
|
!isFunction(res.raw.write)
|
||||||
) {
|
) {
|
||||||
res.header('Content-Type', 'text/event-stream');
|
res.header('Content-Type', 'text/event-stream');
|
||||||
res.header('Cache-Control', 'no-cache');
|
res.header('Cache-Control', 'no-cache');
|
||||||
@@ -248,7 +256,7 @@ export class GeminiController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasHijack(res)) {
|
if (this.supportsReplyHijack(res)) {
|
||||||
res.hijack();
|
res.hijack();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +271,7 @@ export class GeminiController {
|
|||||||
if (res.raw.writableEnded) {
|
if (res.raw.writableEnded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = typeof chunk === 'string' ? chunk : String(chunk ?? '');
|
const payload = isString(chunk) ? chunk : String(chunk ?? '');
|
||||||
res.raw.write(payload);
|
res.raw.write(payload);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
@@ -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';
|
return isFunction((reply as { hijack?: unknown }).hijack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,12 +30,13 @@ export interface OpenAIContentPart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAITool {
|
export interface OpenAITool {
|
||||||
type: 'function';
|
type: string;
|
||||||
function: {
|
function?: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
parameters?: Record<string, unknown>;
|
parameters?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenAIToolCall {
|
export interface OpenAIToolCall {
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Req,
|
Req,
|
||||||
Logger,
|
Logger,
|
||||||
|
Optional,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { isNil } from 'lodash-es';
|
import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es';
|
||||||
import { ProxyService } from './proxy.service';
|
import { ProxyService } from './proxy.service';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
@@ -29,20 +30,27 @@ import {
|
|||||||
MODEL_LIST_OWNER,
|
MODEL_LIST_OWNER,
|
||||||
} from '../../../lib/antigravity/ModelMapping';
|
} from '../../../lib/antigravity/ModelMapping';
|
||||||
import { getServerConfig } from '../../server-config';
|
import { getServerConfig } from '../../server-config';
|
||||||
|
import { TokenManagerService } from './token-manager.service';
|
||||||
|
|
||||||
@Controller('v1')
|
@Controller('v1')
|
||||||
@UseGuards(ProxyGuard)
|
@UseGuards(ProxyGuard)
|
||||||
export class ProxyController {
|
export class ProxyController {
|
||||||
private readonly logger = new Logger(ProxyController.name);
|
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')
|
@Get('models')
|
||||||
listModels(@Res() res: FastifyReply) {
|
listModels(@Res() res: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const config = getServerConfig();
|
const config = getServerConfig();
|
||||||
const customMapping = config?.custom_mapping ?? {};
|
const customMapping = config?.custom_mapping ?? {};
|
||||||
const modelIds = getAllDynamicModels(customMapping);
|
const modelIds = getAllDynamicModels(
|
||||||
|
customMapping,
|
||||||
|
this.tokenManager?.getAllCollectedModels(),
|
||||||
|
);
|
||||||
|
|
||||||
const data = modelIds.map((id) => ({
|
const data = modelIds.map((id) => ({
|
||||||
id,
|
id,
|
||||||
@@ -69,7 +77,7 @@ export class ProxyController {
|
|||||||
|
|
||||||
@Post('chat/completions')
|
@Post('chat/completions')
|
||||||
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
async chatCompletions(@Body() body: OpenAIChatRequest, @Res() res: FastifyReply) {
|
||||||
await this.sendOpenAIChatCompletionResponse(body, res);
|
await this.respondOpenAIChatCompletions(body, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('completions')
|
@Post('completions')
|
||||||
@@ -86,11 +94,11 @@ export class ProxyController {
|
|||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const request: OpenAIChatRequest = {
|
const request: OpenAIChatRequest = {
|
||||||
model: body.model ?? 'gemini-2.5-flash',
|
model: body.model ?? 'gemini-3-flash',
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: this.normalizePrompt(body.prompt),
|
content: this.normalizeCompletionPrompt(body.prompt),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
max_tokens: body.max_tokens,
|
max_tokens: body.max_tokens,
|
||||||
@@ -106,17 +114,9 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = result as OpenAIChatResponse;
|
const response = result as OpenAIChatResponse;
|
||||||
res.status(HttpStatus.OK).send(this.toLegacyCompletionsResponse(response));
|
res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
this.sendOpenAIErrorResponse(res, '/v1/completions', error);
|
||||||
const status = this.resolveUpstreamStatus(message);
|
|
||||||
this.logEndpointError('/v1/completions', status, message, error);
|
|
||||||
res.status(status).send({
|
|
||||||
error: {
|
|
||||||
message,
|
|
||||||
type: 'server_error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +127,7 @@ export class ProxyController {
|
|||||||
model?: string;
|
model?: string;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
|
tools?: OpenAIChatRequest['tools'];
|
||||||
max_output_tokens?: number;
|
max_output_tokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
@@ -144,17 +145,9 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = result as OpenAIChatResponse;
|
const response = result as OpenAIChatResponse;
|
||||||
res.status(HttpStatus.OK).send(this.toLegacyCompletionsResponse(response));
|
res.status(HttpStatus.OK).send(this.toLegacyTextCompletionsResponse(response));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
this.sendOpenAIErrorResponse(res, '/v1/responses', error);
|
||||||
const status = this.resolveUpstreamStatus(message);
|
|
||||||
this.logEndpointError('/v1/responses', status, message, error);
|
|
||||||
res.status(status).send({
|
|
||||||
error: {
|
|
||||||
message,
|
|
||||||
type: 'server_error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +175,7 @@ export class ProxyController {
|
|||||||
quality: body.quality,
|
quality: body.quality,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.sendOpenAIImageResponse(request, body.prompt ?? '', res);
|
await this.sendOpenAIImageGenerationResponse(request, body.prompt ?? '', res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('images/edits')
|
@Post('images/edits')
|
||||||
@@ -200,7 +193,7 @@ export class ProxyController {
|
|||||||
@Req() req: FastifyRequest,
|
@Req() req: FastifyRequest,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
if (!this.hasValidMultipartBoundary(req)) {
|
if (!this.hasMultipartBoundary(req)) {
|
||||||
res
|
res
|
||||||
.status(HttpStatus.BAD_REQUEST)
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
.send('Invalid `boundary` for `multipart/form-data` request');
|
.send('Invalid `boundary` for `multipart/form-data` request');
|
||||||
@@ -236,7 +229,7 @@ export class ProxyController {
|
|||||||
quality: body.quality,
|
quality: body.quality,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.sendOpenAIImageResponse(request, body.prompt ?? '', res);
|
await this.sendOpenAIImageGenerationResponse(request, body.prompt ?? '', res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('audio/transcriptions')
|
@Post('audio/transcriptions')
|
||||||
@@ -251,7 +244,7 @@ export class ProxyController {
|
|||||||
@Req() req: FastifyRequest,
|
@Req() req: FastifyRequest,
|
||||||
@Res() res: FastifyReply,
|
@Res() res: FastifyReply,
|
||||||
) {
|
) {
|
||||||
if (!this.hasValidMultipartBoundary(req)) {
|
if (!this.hasMultipartBoundary(req)) {
|
||||||
res
|
res
|
||||||
.status(HttpStatus.BAD_REQUEST)
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
.send('Invalid `boundary` for `multipart/form-data` request');
|
.send('Invalid `boundary` for `multipart/form-data` request');
|
||||||
@@ -271,7 +264,7 @@ export class ProxyController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.proxyService.handleGeminiGenerateContent(
|
const result = await this.proxyService.handleGeminiGenerateContent(
|
||||||
body.model ?? 'gemini-2.5-flash',
|
body.model ?? 'gemini-3-flash',
|
||||||
{
|
{
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
@@ -298,19 +291,11 @@ export class ProxyController {
|
|||||||
text: text ?? '',
|
text: text ?? '',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
this.sendOpenAIErrorResponse(res, '/v1/audio/transcriptions', error);
|
||||||
const status = this.resolveUpstreamStatus(message);
|
|
||||||
this.logEndpointError('/v1/audio/transcriptions', status, message, error);
|
|
||||||
res.status(status).send({
|
|
||||||
error: {
|
|
||||||
message,
|
|
||||||
type: 'server_error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendOpenAIChatCompletionResponse(body: OpenAIChatRequest, res: FastifyReply) {
|
private async respondOpenAIChatCompletions(body: OpenAIChatRequest, res: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const result = await this.proxyService.handleChatCompletions(body);
|
const result = await this.proxyService.handleChatCompletions(body);
|
||||||
|
|
||||||
@@ -321,15 +306,7 @@ export class ProxyController {
|
|||||||
res.status(HttpStatus.OK).send(result);
|
res.status(HttpStatus.OK).send(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
this.sendOpenAIErrorResponse(res, '/v1/chat/completions', error);
|
||||||
const status = this.resolveUpstreamStatus(message);
|
|
||||||
this.logEndpointError('/v1/chat/completions', status, message, error);
|
|
||||||
res.status(status).send({
|
|
||||||
error: {
|
|
||||||
message: message,
|
|
||||||
type: 'server_error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,20 +322,11 @@ export class ProxyController {
|
|||||||
res.status(HttpStatus.OK).send(result);
|
res.status(HttpStatus.OK).send(result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
this.sendAnthropicErrorResponse(res, '/v1/messages', 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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizePrompt(prompt: string | string[] | undefined): string {
|
private normalizeCompletionPrompt(prompt: string | string[] | undefined): string {
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -368,10 +336,10 @@ export class ProxyController {
|
|||||||
return prompt;
|
return prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toLegacyCompletionsResponse(response: OpenAIChatResponse): Record<string, unknown> {
|
private toLegacyTextCompletionsResponse(response: OpenAIChatResponse): Record<string, unknown> {
|
||||||
const choice = response.choices?.[0];
|
const choice = response.choices?.[0];
|
||||||
const content = choice?.message?.content;
|
const content = choice?.message?.content;
|
||||||
const text = typeof content === 'string' ? content : '';
|
const text = isString(content) ? content : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
@@ -391,21 +359,20 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private normalizeResponsesInput(input: unknown): string {
|
private normalizeResponsesInput(input: unknown): string {
|
||||||
if (typeof input === 'string') {
|
if (isString(input)) {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(input)) {
|
if (Array.isArray(input)) {
|
||||||
return input
|
return input
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
if (typeof item === 'string') {
|
if (isString(item)) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
if (item && typeof item === 'object' && 'content' in item) {
|
const itemRecord = this.toRecord(item);
|
||||||
const content = (item as { content?: unknown }).content;
|
const content = this.asString(itemRecord?.content);
|
||||||
if (typeof content === 'string') {
|
if (content) {
|
||||||
return content;
|
return content;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return JSON.stringify(item);
|
return JSON.stringify(item);
|
||||||
})
|
})
|
||||||
@@ -423,13 +390,14 @@ export class ProxyController {
|
|||||||
model?: string;
|
model?: string;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
|
tools?: OpenAIChatRequest['tools'];
|
||||||
max_output_tokens?: number;
|
max_output_tokens?: number;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
top_p?: number;
|
top_p?: number;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
}): OpenAIChatRequest {
|
}): OpenAIChatRequest {
|
||||||
const messages: OpenAIChatRequest['messages'] = [];
|
const messages: OpenAIChatRequest['messages'] = [];
|
||||||
if (body.instructions && body.instructions.trim() !== '') {
|
if (isString(body.instructions) && !isEmpty(body.instructions.trim())) {
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: body.instructions,
|
content: body.instructions,
|
||||||
@@ -458,7 +426,7 @@ export class ProxyController {
|
|||||||
type === 'local_shell_call'
|
type === 'local_shell_call'
|
||||||
? 'shell'
|
? 'shell'
|
||||||
: type === 'web_search_call'
|
: type === 'web_search_call'
|
||||||
? 'google_search'
|
? 'builtin_web_search'
|
||||||
: (this.asString(itemObj.name) ?? 'unknown');
|
: (this.asString(itemObj.name) ?? 'unknown');
|
||||||
callIdToToolName.set(callId, toolName);
|
callIdToToolName.set(callId, toolName);
|
||||||
}
|
}
|
||||||
@@ -516,7 +484,7 @@ export class ProxyController {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (typeof body.input === 'string') {
|
} else if (isString(body.input)) {
|
||||||
messages.push({
|
messages.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: body.input,
|
content: body.input,
|
||||||
@@ -536,8 +504,9 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
model: body.model ?? 'gemini-2.5-flash',
|
model: body.model ?? 'gemini-3-flash',
|
||||||
messages,
|
messages,
|
||||||
|
tools: body.tools,
|
||||||
max_tokens: body.max_output_tokens,
|
max_tokens: body.max_output_tokens,
|
||||||
temperature: body.temperature,
|
temperature: body.temperature,
|
||||||
top_p: body.top_p,
|
top_p: body.top_p,
|
||||||
@@ -546,7 +515,7 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private normalizeResponsesMessageContent(content: unknown): string | OpenAIContentPart[] {
|
private normalizeResponsesMessageContent(content: unknown): string | OpenAIContentPart[] {
|
||||||
if (typeof content === 'string') {
|
if (isString(content)) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,11 +590,12 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const raw = item.arguments;
|
const raw = item.arguments;
|
||||||
if (typeof raw === 'string') {
|
if (isString(raw)) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
const parsedRecord = this.toRecord(parsed);
|
||||||
return parsed as Record<string, unknown>;
|
if (parsedRecord) {
|
||||||
|
return parsedRecord;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
value: parsed,
|
value: parsed,
|
||||||
@@ -637,23 +607,22 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
const rawRecord = this.toRecord(raw);
|
||||||
return raw as Record<string, unknown>;
|
if (rawRecord) {
|
||||||
|
return rawRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeResponsesOutput(output: unknown): string {
|
private normalizeResponsesOutput(output: unknown): string {
|
||||||
if (typeof output === 'string') {
|
if (isString(output)) {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
if (output && typeof output === 'object' && !Array.isArray(output)) {
|
const outputRecord = this.toRecord(output);
|
||||||
const obj = output as Record<string, unknown>;
|
const content = this.asString(outputRecord?.content);
|
||||||
const content = this.asString(obj.content);
|
if (content) {
|
||||||
if (content) {
|
return content;
|
||||||
return content;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (isNil(output)) {
|
if (isNil(output)) {
|
||||||
return '';
|
return '';
|
||||||
@@ -663,15 +632,13 @@ export class ProxyController {
|
|||||||
|
|
||||||
private resolveImageUrl(block: Record<string, unknown>): string | null {
|
private resolveImageUrl(block: Record<string, unknown>): string | null {
|
||||||
const raw = block.image_url;
|
const raw = block.image_url;
|
||||||
if (typeof raw === 'string') {
|
if (isString(raw)) {
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
const rawRecord = this.toRecord(raw);
|
||||||
const record = raw as Record<string, unknown>;
|
const url = this.asString(rawRecord?.url);
|
||||||
const url = this.asString(record.url);
|
if (url) {
|
||||||
if (url) {
|
return url;
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -703,7 +670,7 @@ export class ProxyController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof input === 'string') {
|
if (isString(input)) {
|
||||||
const dataUri = input.match(/^data:(?<mime>[^;]+);base64,(?<data>[A-Za-z0-9+/=]+)$/);
|
const dataUri = input.match(/^data:(?<mime>[^;]+);base64,(?<data>[A-Za-z0-9+/=]+)$/);
|
||||||
if (dataUri?.groups?.mime && dataUri.groups.data) {
|
if (dataUri?.groups?.mime && dataUri.groups.data) {
|
||||||
return {
|
return {
|
||||||
@@ -722,14 +689,14 @@ export class ProxyController {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof input === 'object' && !Array.isArray(input)) {
|
const inputRecord = this.toRecord(input);
|
||||||
const obj = input as Record<string, unknown>;
|
if (inputRecord) {
|
||||||
const data = this.asString(obj.data);
|
const data = this.asString(inputRecord.data);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mimeType: this.asString(obj.mimeType) ?? 'audio/mpeg',
|
mimeType: this.asString(inputRecord.mimeType) ?? 'audio/mpeg',
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -738,29 +705,25 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toRecord(value: unknown): Record<string, unknown> | null {
|
private toRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!isPlainObject(value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
private asString(value: unknown): string | null {
|
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> {
|
private isObservableLike(value: unknown): value is Observable<unknown> {
|
||||||
return (
|
return isObjectLike(value) && isFunction((value as { subscribe?: unknown }).subscribe);
|
||||||
Boolean(value) &&
|
|
||||||
typeof value === 'object' &&
|
|
||||||
typeof (value as { subscribe?: unknown }).subscribe === 'function'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
|
private writeSseResponse(res: FastifyReply, stream: Observable<unknown>): void {
|
||||||
if (
|
if (
|
||||||
!res.raw ||
|
!res.raw ||
|
||||||
typeof res.raw.writeHead !== 'function' ||
|
!isFunction(res.raw.writeHead) ||
|
||||||
typeof res.raw.write !== 'function'
|
!isFunction(res.raw.write)
|
||||||
) {
|
) {
|
||||||
res.header('Content-Type', 'text/event-stream');
|
res.header('Content-Type', 'text/event-stream');
|
||||||
res.header('Cache-Control', 'no-cache');
|
res.header('Cache-Control', 'no-cache');
|
||||||
@@ -769,7 +732,7 @@ export class ProxyController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof (res as { hijack?: () => void }).hijack === 'function') {
|
if (isFunction((res as { hijack?: () => void }).hijack)) {
|
||||||
(res as { hijack: () => void }).hijack();
|
(res as { hijack: () => void }).hijack();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,7 +747,7 @@ export class ProxyController {
|
|||||||
if (res.raw.writableEnded) {
|
if (res.raw.writableEnded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = typeof chunk === 'string' ? chunk : String(chunk ?? '');
|
const payload = isString(chunk) ? chunk : String(chunk ?? '');
|
||||||
res.raw.write(payload);
|
res.raw.write(payload);
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
@@ -814,7 +777,7 @@ export class ProxyController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendOpenAIImageResponse(
|
private async sendOpenAIImageGenerationResponse(
|
||||||
request: OpenAIChatRequest,
|
request: OpenAIChatRequest,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
res: FastifyReply,
|
res: FastifyReply,
|
||||||
@@ -822,7 +785,7 @@ export class ProxyController {
|
|||||||
try {
|
try {
|
||||||
const result = await this.proxyService.handleChatCompletions(request);
|
const result = await this.proxyService.handleChatCompletions(request);
|
||||||
if (result instanceof Observable) {
|
if (result instanceof Observable) {
|
||||||
this.logEndpointError(
|
this.logProxyEndpointError(
|
||||||
'/v1/images/generations',
|
'/v1/images/generations',
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
'Streaming image generation is not supported by this endpoint',
|
'Streaming image generation is not supported by this endpoint',
|
||||||
@@ -837,9 +800,9 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = result.choices?.[0]?.message?.content;
|
const content = result.choices?.[0]?.message?.content;
|
||||||
const image = this.extractInlineBase64Image(typeof content === 'string' ? content : '');
|
const image = this.extractInlineBase64Image(isString(content) ? content : '');
|
||||||
if (!image) {
|
if (!image) {
|
||||||
this.logEndpointError(
|
this.logProxyEndpointError(
|
||||||
'/v1/images/generations',
|
'/v1/images/generations',
|
||||||
HttpStatus.BAD_GATEWAY,
|
HttpStatus.BAD_GATEWAY,
|
||||||
'Upstream did not return inline image data',
|
'Upstream did not return inline image data',
|
||||||
@@ -889,14 +852,7 @@ export class ProxyController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = this.resolveUpstreamStatus(message);
|
this.sendOpenAIErrorResponse(res, '/v1/images/generations', error, message);
|
||||||
this.logEndpointError('/v1/images/generations', status, message, error);
|
|
||||||
res.status(status).send({
|
|
||||||
error: {
|
|
||||||
message,
|
|
||||||
type: 'server_error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,7 +904,7 @@ export class ProxyController {
|
|||||||
|
|
||||||
if (!userMessage) {
|
if (!userMessage) {
|
||||||
parts.push({ text: fallbackPrompt || 'Please generate an image based on this request.' });
|
parts.push({ text: fallbackPrompt || 'Please generate an image based on this request.' });
|
||||||
} else if (typeof userMessage.content === 'string') {
|
} else if (isString(userMessage.content)) {
|
||||||
parts.push({
|
parts.push({
|
||||||
text:
|
text:
|
||||||
userMessage.content ||
|
userMessage.content ||
|
||||||
@@ -957,7 +913,7 @@ export class ProxyController {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
for (const block of userMessage.content) {
|
for (const block of userMessage.content) {
|
||||||
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
|
if (block.type === 'text' && isString(block.text) && !isEmpty(block.text.trim())) {
|
||||||
textParts.push(block.text);
|
textParts.push(block.text);
|
||||||
}
|
}
|
||||||
if (block.type === 'image_url') {
|
if (block.type === 'image_url') {
|
||||||
@@ -1002,9 +958,9 @@ export class ProxyController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasValidMultipartBoundary(req: FastifyRequest): boolean {
|
private hasMultipartBoundary(req: FastifyRequest): boolean {
|
||||||
const contentType = req.headers['content-type'];
|
const contentType = req.headers['content-type'];
|
||||||
if (typeof contentType !== 'string') {
|
if (!isString(contentType)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,7 +968,46 @@ export class ProxyController {
|
|||||||
return lowered.includes('multipart/form-data') && lowered.includes('boundary=');
|
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();
|
const lowered = message.toLowerCase();
|
||||||
if (lowered.includes('all accounts failed or unhealthy')) {
|
if (lowered.includes('all accounts failed or unhealthy')) {
|
||||||
return HttpStatus.SERVICE_UNAVAILABLE;
|
return HttpStatus.SERVICE_UNAVAILABLE;
|
||||||
@@ -1050,7 +1045,7 @@ export class ProxyController {
|
|||||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
return HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
private logEndpointError(
|
private logProxyEndpointError(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
status: HttpStatus,
|
status: HttpStatus,
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
import { isEmpty, isNil } from 'lodash-es';
|
import { isEmpty, isNil, isNumber, isPlainObject, isString } from 'lodash-es';
|
||||||
import { TokenManagerService } from './token-manager.service';
|
import { TokenManagerService } from './token-manager.service';
|
||||||
import { GeminiClient } from './clients/gemini.client';
|
import { GeminiClient } from './clients/gemini.client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@@ -14,10 +14,8 @@ import {
|
|||||||
GeminiPart as InternalGeminiPart,
|
GeminiPart as InternalGeminiPart,
|
||||||
} from '../../../lib/antigravity/types';
|
} from '../../../lib/antigravity/types';
|
||||||
import { calculateRetryDelay, sleep } from '../../../lib/antigravity/retry-utils';
|
import { calculateRetryDelay, sleep } from '../../../lib/antigravity/retry-utils';
|
||||||
import {
|
import { normalizeObjectJsonSchema } from '../../../lib/antigravity/JsonSchemaUtils';
|
||||||
classifyStreamError,
|
import { classifyStreamError } from '../../../lib/antigravity/stream-error-utils';
|
||||||
formatErrorForSSE,
|
|
||||||
} from '../../../lib/antigravity/stream-error-utils';
|
|
||||||
import {
|
import {
|
||||||
OpenAIChatRequest,
|
OpenAIChatRequest,
|
||||||
AnthropicChatRequest,
|
AnthropicChatRequest,
|
||||||
@@ -28,12 +26,17 @@ import {
|
|||||||
AnthropicContent,
|
AnthropicContent,
|
||||||
} from './interfaces/request-interfaces';
|
} from './interfaces/request-interfaces';
|
||||||
import { getServerConfig } from '../../server-config';
|
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()
|
@Injectable()
|
||||||
export class ProxyService {
|
export class ProxyService {
|
||||||
private readonly logger = new Logger(ProxyService.name);
|
private readonly logger = new Logger(ProxyService.name);
|
||||||
private readonly defaultRequestUserAgent = 'antigravity/1.11.9 windows/amd64';
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
|
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
|
||||||
@@ -45,12 +48,12 @@ export class ProxyService {
|
|||||||
async handleAnthropicMessages(
|
async handleAnthropicMessages(
|
||||||
request: AnthropicChatRequest,
|
request: AnthropicChatRequest,
|
||||||
): Promise<AnthropicChatResponse | Observable<string>> {
|
): Promise<AnthropicChatResponse | Observable<string>> {
|
||||||
const sessionKey = this.getSessionKeyFromAnthropic(request);
|
const sessionKey = this.extractAnthropicSessionKey(request);
|
||||||
|
|
||||||
const targetModel = this.mapModel(request.model);
|
const targetModel = this.resolveTargetModel(request.model);
|
||||||
const extraHeaders = this.buildModelSpecificHeaders(request.model);
|
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||||
this.logger.log(
|
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
|
// Retry loop
|
||||||
@@ -61,22 +64,34 @@ export class ProxyService {
|
|||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
const delay = calculateRetryDelay(i - 1);
|
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);
|
await sleep(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await this.tokenManager.getNextToken({
|
const token = await this.tokenManager.getNextToken({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||||
|
model: targetModel,
|
||||||
});
|
});
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('No available accounts');
|
throw new Error('No available accounts');
|
||||||
}
|
}
|
||||||
attemptedAccountIds.add(token.id);
|
attemptedAccountIds.add(token.id);
|
||||||
|
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||||
|
token.id,
|
||||||
|
targetModel,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = token.token.project_id ?? '';
|
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) {
|
if (request.stream) {
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
@@ -101,7 +116,14 @@ export class ProxyService {
|
|||||||
`Anthropic request hit project context issue, retrying without project: ${error.message}`,
|
`Anthropic request hit project context issue, retrying without project: ${error.message}`,
|
||||||
);
|
);
|
||||||
try {
|
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) {
|
if (request.stream) {
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
fallbackBody,
|
fallbackBody,
|
||||||
@@ -126,17 +148,20 @@ export class ProxyService {
|
|||||||
|
|
||||||
if (error instanceof Error && this.isQuotaExhaustedError(error.message)) {
|
if (error instanceof Error && this.isQuotaExhaustedError(error.message)) {
|
||||||
this.logger.warn(
|
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 {
|
try {
|
||||||
const downgradedRequest: ClaudeRequest = {
|
const downgradedRequest: ClaudeRequest = {
|
||||||
...this.toClaudeRequest(request),
|
...this.toClaudeRequest(request),
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-3-flash',
|
||||||
};
|
};
|
||||||
|
const requestUserAgent = await resolveRequestUserAgent();
|
||||||
const downgradedBody = transformClaudeRequestIn(
|
const downgradedBody = transformClaudeRequestIn(
|
||||||
downgradedRequest,
|
downgradedRequest,
|
||||||
token.token.project_id ?? '',
|
token.token.project_id ?? '',
|
||||||
|
requestUserAgent,
|
||||||
);
|
);
|
||||||
|
this.applyInternalGenerationConstraints(downgradedBody, 'gemini-3-flash', token.id);
|
||||||
if (request.stream) {
|
if (request.stream) {
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
downgradedBody,
|
downgradedBody,
|
||||||
@@ -164,17 +189,7 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (error instanceof Error) {
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError || new Error('Request failed after retries');
|
throw lastError || new Error('Request failed after retries');
|
||||||
@@ -182,7 +197,7 @@ export class ProxyService {
|
|||||||
|
|
||||||
private processAnthropicInternalStream(
|
private processAnthropicInternalStream(
|
||||||
upstreamStream: NodeJS.ReadableStream,
|
upstreamStream: NodeJS.ReadableStream,
|
||||||
model: string,
|
_model: string,
|
||||||
): Observable<string> {
|
): Observable<string> {
|
||||||
return new Observable<string>((subscriber) => {
|
return new Observable<string>((subscriber) => {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@@ -255,12 +270,9 @@ export class ProxyService {
|
|||||||
|
|
||||||
upstreamStream.on('error', (err: unknown) => {
|
upstreamStream.on('error', (err: unknown) => {
|
||||||
const cleanError = err instanceof Error ? err : new Error(String(err));
|
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}`);
|
this.logger.error(`Stream error: ${type} - ${cleanError.message}`);
|
||||||
|
|
||||||
// Send SSE error event before closing
|
|
||||||
subscriber.next(formatErrorForSSE(type, message));
|
|
||||||
subscriber.error(cleanError);
|
subscriber.error(cleanError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -272,10 +284,10 @@ export class ProxyService {
|
|||||||
request: GeminiRequest,
|
request: GeminiRequest,
|
||||||
): Promise<GeminiResponse> {
|
): Promise<GeminiResponse> {
|
||||||
const normalizedModel = this.normalizeGeminiModel(model);
|
const normalizedModel = this.normalizeGeminiModel(model);
|
||||||
const targetModel = this.mapModel(normalizedModel);
|
const targetModel = this.resolveTargetModel(normalizedModel);
|
||||||
const extraHeaders = this.buildModelSpecificHeaders(normalizedModel);
|
const extraHeaders = this.createModelSpecificHeaders(normalizedModel);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Received Gemini request for model: ${normalizedModel} (Mapped: ${targetModel})`,
|
`Gemini generate request received: model=${normalizedModel}, mappedModel=${targetModel}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
@@ -291,19 +303,27 @@ export class ProxyService {
|
|||||||
|
|
||||||
const token = await this.tokenManager.getNextToken({
|
const token = await this.tokenManager.getNextToken({
|
||||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||||
|
model: targetModel,
|
||||||
});
|
});
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('No available accounts (all exhausted or rate limited)');
|
throw new Error('No available accounts (all exhausted or rate limited)');
|
||||||
}
|
}
|
||||||
attemptedAccountIds.add(token.id);
|
attemptedAccountIds.add(token.id);
|
||||||
|
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||||
|
token.id,
|
||||||
|
targetModel,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestUserAgent = await resolveRequestUserAgent();
|
||||||
const internalBody = this.createGeminiInternalRequest(
|
const internalBody = this.createGeminiInternalRequest(
|
||||||
targetModel,
|
effectiveTargetModel,
|
||||||
request,
|
request,
|
||||||
token.token.project_id ?? '',
|
token.token.project_id ?? '',
|
||||||
'generate-content',
|
'generate-content',
|
||||||
|
requestUserAgent,
|
||||||
);
|
);
|
||||||
|
this.applyInternalGenerationConstraints(internalBody, effectiveTargetModel, token.id);
|
||||||
|
|
||||||
const response = await this.generateInternalWithStreamFallback(
|
const response = await this.generateInternalWithStreamFallback(
|
||||||
internalBody,
|
internalBody,
|
||||||
@@ -319,12 +339,15 @@ export class ProxyService {
|
|||||||
`Gemini request hit project context issue, retrying without project: ${err.message}`,
|
`Gemini request hit project context issue, retrying without project: ${err.message}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
const requestUserAgent = await resolveRequestUserAgent();
|
||||||
const fallbackBody = this.createGeminiInternalRequest(
|
const fallbackBody = this.createGeminiInternalRequest(
|
||||||
targetModel,
|
effectiveTargetModel,
|
||||||
request,
|
request,
|
||||||
'',
|
'',
|
||||||
'generate-content',
|
'generate-content',
|
||||||
|
requestUserAgent,
|
||||||
);
|
);
|
||||||
|
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||||
const response = await this.generateInternalWithStreamFallback(
|
const response = await this.generateInternalWithStreamFallback(
|
||||||
fallbackBody,
|
fallbackBody,
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
@@ -339,17 +362,7 @@ export class ProxyService {
|
|||||||
lastError = err;
|
lastError = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastError instanceof Error) {
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,10 +374,10 @@ export class ProxyService {
|
|||||||
request: GeminiRequest,
|
request: GeminiRequest,
|
||||||
): Promise<Observable<string>> {
|
): Promise<Observable<string>> {
|
||||||
const normalizedModel = this.normalizeGeminiModel(model);
|
const normalizedModel = this.normalizeGeminiModel(model);
|
||||||
const targetModel = this.mapModel(normalizedModel);
|
const targetModel = this.resolveTargetModel(normalizedModel);
|
||||||
const extraHeaders = this.buildModelSpecificHeaders(normalizedModel);
|
const extraHeaders = this.createModelSpecificHeaders(normalizedModel);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Received Gemini stream request for model: ${normalizedModel} (Mapped: ${targetModel})`,
|
`Gemini stream request received: model=${normalizedModel}, mappedModel=${targetModel}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
@@ -380,19 +393,27 @@ export class ProxyService {
|
|||||||
|
|
||||||
const token = await this.tokenManager.getNextToken({
|
const token = await this.tokenManager.getNextToken({
|
||||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||||
|
model: targetModel,
|
||||||
});
|
});
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('No available accounts (all exhausted or rate limited)');
|
throw new Error('No available accounts (all exhausted or rate limited)');
|
||||||
}
|
}
|
||||||
attemptedAccountIds.add(token.id);
|
attemptedAccountIds.add(token.id);
|
||||||
|
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||||
|
token.id,
|
||||||
|
targetModel,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestUserAgent = await resolveRequestUserAgent();
|
||||||
const internalBody = this.createGeminiInternalRequest(
|
const internalBody = this.createGeminiInternalRequest(
|
||||||
targetModel,
|
effectiveTargetModel,
|
||||||
request,
|
request,
|
||||||
token.token.project_id ?? '',
|
token.token.project_id ?? '',
|
||||||
'generate-content',
|
'generate-content',
|
||||||
|
requestUserAgent,
|
||||||
);
|
);
|
||||||
|
this.applyInternalGenerationConstraints(internalBody, effectiveTargetModel, token.id);
|
||||||
|
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
internalBody,
|
internalBody,
|
||||||
@@ -407,12 +428,15 @@ export class ProxyService {
|
|||||||
`Gemini stream request hit project context issue, retrying without project: ${err.message}`,
|
`Gemini stream request hit project context issue, retrying without project: ${err.message}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
const requestUserAgent = await resolveRequestUserAgent();
|
||||||
const fallbackBody = this.createGeminiInternalRequest(
|
const fallbackBody = this.createGeminiInternalRequest(
|
||||||
targetModel,
|
effectiveTargetModel,
|
||||||
request,
|
request,
|
||||||
'',
|
'',
|
||||||
'generate-content',
|
'generate-content',
|
||||||
|
requestUserAgent,
|
||||||
);
|
);
|
||||||
|
this.applyInternalGenerationConstraints(fallbackBody, effectiveTargetModel, token.id);
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
fallbackBody,
|
fallbackBody,
|
||||||
token.token.access_token,
|
token.token.access_token,
|
||||||
@@ -427,19 +451,7 @@ export class ProxyService {
|
|||||||
lastError = err;
|
lastError = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastError instanceof Error) {
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,11 +487,130 @@ export class ProxyService {
|
|||||||
return model.replace(/^models\//i, '');
|
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 (isNumber(dynamicCap) && 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 (isNumber(dynamicBudget) && 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 &&
|
||||||
|
(isString(thinkingConfig.thinkingLevel) ||
|
||||||
|
thinkingConfig.thinkingBudget === -1 ||
|
||||||
|
thinkingConfig.thinkingBudget === 32768);
|
||||||
|
|
||||||
|
if (thinkingConfig) {
|
||||||
|
if (!isClaudeModel && isString(thinkingConfig.thinkingLevel)) {
|
||||||
|
const converted = this.resolveThinkingLevelBudget(thinkingConfig.thinkingLevel);
|
||||||
|
if (converted !== undefined) {
|
||||||
|
thinkingConfig.thinkingBudget = converted;
|
||||||
|
}
|
||||||
|
delete thinkingConfig.thinkingLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNumber(thinkingConfig.thinkingBudget) && thinkingConfig.thinkingBudget < 0) {
|
||||||
|
thinkingConfig.thinkingBudget = Math.min(thinkingBudgetCap, 24576);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isNumber(thinkingConfig.thinkingBudget) &&
|
||||||
|
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 (
|
||||||
|
isNumber(generationConfig.maxOutputTokens) &&
|
||||||
|
Number.isFinite(generationConfig.maxOutputTokens)
|
||||||
|
) {
|
||||||
|
generationConfig.maxOutputTokens = Math.min(
|
||||||
|
Math.floor(generationConfig.maxOutputTokens),
|
||||||
|
outputCap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private createGeminiInternalRequest(
|
private createGeminiInternalRequest(
|
||||||
model: string,
|
model: string,
|
||||||
request: GeminiRequest,
|
request: GeminiRequest,
|
||||||
projectId: string | undefined,
|
projectId: string | undefined,
|
||||||
requestType: string,
|
requestType: string,
|
||||||
|
requestUserAgent: string,
|
||||||
): GeminiInternalRequest {
|
): GeminiInternalRequest {
|
||||||
const normalizedProjectId = projectId?.trim();
|
const normalizedProjectId = projectId?.trim();
|
||||||
|
|
||||||
@@ -487,7 +618,7 @@ export class ProxyService {
|
|||||||
requestId: uuidv4(),
|
requestId: uuidv4(),
|
||||||
request: this.toInternalGeminiRequest(request),
|
request: this.toInternalGeminiRequest(request),
|
||||||
model,
|
model,
|
||||||
userAgent: process.env.PROXY_REQUEST_USER_AGENT ?? this.defaultRequestUserAgent,
|
userAgent: requestUserAgent,
|
||||||
requestType,
|
requestType,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -503,7 +634,7 @@ export class ProxyService {
|
|||||||
? response.candidates.map((candidate, index) => ({
|
? response.candidates.map((candidate, index) => ({
|
||||||
content: candidate?.content,
|
content: candidate?.content,
|
||||||
finishReason: candidate?.finishReason,
|
finishReason: candidate?.finishReason,
|
||||||
index: typeof candidate?.index === 'number' ? candidate.index : index,
|
index: isNumber(candidate?.index) ? candidate.index : index,
|
||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -523,9 +654,6 @@ export class ProxyService {
|
|||||||
if (usage.totalTokenCount !== undefined) {
|
if (usage.totalTokenCount !== undefined) {
|
||||||
usageMetadata.totalTokenCount = usage.totalTokenCount;
|
usageMetadata.totalTokenCount = usage.totalTokenCount;
|
||||||
}
|
}
|
||||||
if (usage.thoughtsTokenCount !== undefined) {
|
|
||||||
usageMetadata.thoughtsTokenCount = usage.thoughtsTokenCount;
|
|
||||||
}
|
|
||||||
if (usage.promptTokensDetails !== undefined) {
|
if (usage.promptTokensDetails !== undefined) {
|
||||||
usageMetadata.promptTokensDetails = usage.promptTokensDetails;
|
usageMetadata.promptTokensDetails = usage.promptTokensDetails;
|
||||||
}
|
}
|
||||||
@@ -546,12 +674,12 @@ export class ProxyService {
|
|||||||
async handleChatCompletions(
|
async handleChatCompletions(
|
||||||
request: OpenAIChatRequest,
|
request: OpenAIChatRequest,
|
||||||
): Promise<OpenAIChatResponse | Observable<string>> {
|
): Promise<OpenAIChatResponse | Observable<string>> {
|
||||||
const sessionKey = this.getSessionKeyFromOpenAI(request);
|
const sessionKey = this.extractOpenAISessionKey(request);
|
||||||
|
|
||||||
const targetModel = this.mapModel(request.model);
|
const targetModel = this.resolveTargetModel(request.model);
|
||||||
const extraHeaders = this.buildModelSpecificHeaders(request.model);
|
const extraHeaders = this.createModelSpecificHeaders(request.model);
|
||||||
this.logger.log(
|
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
|
// Retry loop for account selection
|
||||||
@@ -562,7 +690,9 @@ export class ProxyService {
|
|||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
const delay = calculateRetryDelay(i - 1);
|
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);
|
await sleep(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,16 +700,24 @@ export class ProxyService {
|
|||||||
const token = await this.tokenManager.getNextToken({
|
const token = await this.tokenManager.getNextToken({
|
||||||
sessionKey,
|
sessionKey,
|
||||||
excludeAccountIds: Array.from(attemptedAccountIds),
|
excludeAccountIds: Array.from(attemptedAccountIds),
|
||||||
|
model: targetModel,
|
||||||
});
|
});
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('No available accounts (all exhausted or rate limited)');
|
throw new Error('No available accounts (all exhausted or rate limited)');
|
||||||
}
|
}
|
||||||
attemptedAccountIds.add(token.id);
|
attemptedAccountIds.add(token.id);
|
||||||
|
const effectiveTargetModel = this.tokenManager.resolveDynamicModelForAccount(
|
||||||
|
token.id,
|
||||||
|
targetModel,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const claudeRequest = this.convertOpenAIToClaude(request);
|
const claudeRequest = this.convertOpenAIToClaude(request);
|
||||||
const projectId = token.token.project_id ?? '';
|
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)
|
// Use v1internal API (same as Anthropic handler)
|
||||||
if (request.stream) {
|
if (request.stream) {
|
||||||
@@ -593,7 +731,7 @@ export class ProxyService {
|
|||||||
return this.processStreamResponse(stream, request.model);
|
return this.processStreamResponse(stream, request.model);
|
||||||
} catch (streamError) {
|
} catch (streamError) {
|
||||||
this.logger.warn(
|
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)
|
streamError instanceof Error ? streamError.message : String(streamError)
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
@@ -605,7 +743,7 @@ export class ProxyService {
|
|||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
this.logger.log(
|
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 claudeResponse = transformResponse(response);
|
||||||
const openaiResponse = this.convertClaudeToOpenAIResponse(
|
const openaiResponse = this.convertClaudeToOpenAIResponse(
|
||||||
@@ -622,11 +760,13 @@ export class ProxyService {
|
|||||||
extraHeaders,
|
extraHeaders,
|
||||||
);
|
);
|
||||||
this.logger.log(
|
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
|
// Transform Gemini response to OpenAI format
|
||||||
const claudeResponse = transformResponse(response);
|
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);
|
return this.convertClaudeToOpenAIResponse(claudeResponse, request.model);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -636,7 +776,10 @@ export class ProxyService {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const claudeRequest = this.convertOpenAIToClaude(request);
|
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) {
|
if (request.stream) {
|
||||||
const stream = await this.geminiClient.streamGenerateInternal(
|
const stream = await this.geminiClient.streamGenerateInternal(
|
||||||
fallbackBody,
|
fallbackBody,
|
||||||
@@ -662,18 +805,7 @@ export class ProxyService {
|
|||||||
lastError = err;
|
lastError = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastError instanceof Error) {
|
await this.applyUpstreamPenalty(token.id, effectiveTargetModel, lastError);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError || new Error('Request failed after retries');
|
throw lastError || new Error('Request failed after retries');
|
||||||
@@ -937,7 +1069,7 @@ export class ProxyService {
|
|||||||
hasSentDone = true;
|
hasSentDone = true;
|
||||||
subscriber.complete();
|
subscriber.complete();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -969,6 +1101,7 @@ export class ProxyService {
|
|||||||
upstreamStream.on('error', (err: unknown) => {
|
upstreamStream.on('error', (err: unknown) => {
|
||||||
// Convert to clean Error to avoid circular reference issues (socket objects)
|
// Convert to clean Error to avoid circular reference issues (socket objects)
|
||||||
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
|
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);
|
subscriber.error(cleanError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -982,7 +1115,7 @@ export class ProxyService {
|
|||||||
const choice = response.choices?.[0];
|
const choice = response.choices?.[0];
|
||||||
const finishReason = choice?.finish_reason ?? 'stop';
|
const finishReason = choice?.finish_reason ?? 'stop';
|
||||||
const content =
|
const content =
|
||||||
choice?.message && typeof choice.message.content === 'string' ? choice.message.content : '';
|
choice?.message && isString(choice.message.content) ? choice.message.content : '';
|
||||||
const chunkSize = 80;
|
const chunkSize = 80;
|
||||||
|
|
||||||
if (content.length === 0) {
|
if (content.length === 0) {
|
||||||
@@ -1083,7 +1216,7 @@ export class ProxyService {
|
|||||||
systemInstruction: request.systemInstruction
|
systemInstruction: request.systemInstruction
|
||||||
? {
|
? {
|
||||||
parts: request.systemInstruction.parts
|
parts: request.systemInstruction.parts
|
||||||
.filter((part): part is { text: string } => typeof part.text === 'string')
|
.filter((part): part is { text: string } => isString(part.text))
|
||||||
.map((part) => ({ text: part.text })),
|
.map((part) => ({ text: part.text })),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -1161,7 +1294,7 @@ export class ProxyService {
|
|||||||
private convertOpenAIPartsToAnthropicContent(
|
private convertOpenAIPartsToAnthropicContent(
|
||||||
content: OpenAIChatRequest['messages'][number]['content'],
|
content: OpenAIChatRequest['messages'][number]['content'],
|
||||||
): AnthropicContent[] {
|
): AnthropicContent[] {
|
||||||
if (typeof content === 'string') {
|
if (isString(content)) {
|
||||||
return content.trim() ? [{ type: 'text', text: content }] : [];
|
return content.trim() ? [{ type: 'text', text: content }] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,7 +1328,7 @@ export class ProxyService {
|
|||||||
private extractOpenAITextContent(
|
private extractOpenAITextContent(
|
||||||
content: OpenAIChatRequest['messages'][number]['content'],
|
content: OpenAIChatRequest['messages'][number]['content'],
|
||||||
): string {
|
): string {
|
||||||
if (typeof content === 'string') {
|
if (isString(content)) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1206,14 +1339,14 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseOpenAIFunctionArguments(argumentsString: string): Record<string, unknown> {
|
private parseOpenAIFunctionArguments(argumentsString: string): Record<string, unknown> {
|
||||||
if (!argumentsString || argumentsString.trim() === '') {
|
if (isEmpty(argumentsString.trim())) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(argumentsString);
|
const parsed = JSON.parse(argumentsString);
|
||||||
if (this.isRecord(parsed)) {
|
if (isPlainObject(parsed)) {
|
||||||
return parsed;
|
return parsed as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
return { value: parsed };
|
return { value: parsed };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1228,11 +1361,51 @@ export class ProxyService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tools.map((tool) => ({
|
const result: NonNullable<AnthropicChatRequest['tools']> = [];
|
||||||
name: tool.function.name,
|
const searchToolTypes = new Set([
|
||||||
description: tool.function.description,
|
'web_search_20250305',
|
||||||
input_schema: tool.function.parameters || { type: 'object', properties: {} },
|
'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 {
|
private mapGeminiFinishReasonToOpenAIFinishReason(finishReason?: string): string | null {
|
||||||
@@ -1273,7 +1446,7 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private normalizeToolCallArguments(input: unknown): string {
|
private normalizeToolCallArguments(input: unknown): string {
|
||||||
if (typeof input === 'string') {
|
if (isString(input)) {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
if (isNil(input)) {
|
if (isNil(input)) {
|
||||||
@@ -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 normalizedModel = model.replace(/^models\//i, '').trim();
|
||||||
const config = getServerConfig();
|
const config = getServerConfig();
|
||||||
const configuredMapping = {
|
const configuredMapping = {
|
||||||
@@ -1400,23 +1573,66 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const routedModel = resolveModelRoute(normalizedModel, customExactMapping, {}, {});
|
const routedModel = resolveModelRoute(normalizedModel, customExactMapping, {}, {});
|
||||||
|
return normalizeGeminiModelAlias(routedModel);
|
||||||
return 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;
|
retry: boolean;
|
||||||
markAsForbidden: boolean;
|
markAsForbidden: boolean;
|
||||||
markAsRateLimited: boolean;
|
markAsRateLimited: boolean;
|
||||||
} {
|
} {
|
||||||
const msg = errorMessage.toLowerCase();
|
const normalizedErrorMessage = errorMessage.toLowerCase();
|
||||||
const isForbidden =
|
const isForbidden =
|
||||||
msg.includes('401') ||
|
normalizedErrorMessage.includes('401') ||
|
||||||
msg.includes('unauthorized') ||
|
normalizedErrorMessage.includes('unauthorized') ||
|
||||||
msg.includes('invalid_grant') ||
|
normalizedErrorMessage.includes('invalid_grant') ||
|
||||||
msg.includes('403') ||
|
normalizedErrorMessage.includes('403') ||
|
||||||
msg.includes('permission_denied') ||
|
normalizedErrorMessage.includes('permission_denied') ||
|
||||||
msg.includes('forbidden');
|
normalizedErrorMessage.includes('forbidden');
|
||||||
|
|
||||||
if (isForbidden) {
|
if (isForbidden) {
|
||||||
return {
|
return {
|
||||||
@@ -1427,28 +1643,28 @@ export class ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isRateLimitedSignal =
|
const isRateLimitedSignal =
|
||||||
msg.includes('429') ||
|
normalizedErrorMessage.includes('429') ||
|
||||||
msg.includes('resource_exhausted') ||
|
normalizedErrorMessage.includes('resource_exhausted') ||
|
||||||
msg.includes('quota') ||
|
normalizedErrorMessage.includes('quota') ||
|
||||||
msg.includes('rate_limit') ||
|
normalizedErrorMessage.includes('rate_limit') ||
|
||||||
msg.includes('rate limit');
|
normalizedErrorMessage.includes('rate limit');
|
||||||
|
|
||||||
const shouldRetryByStatus =
|
const shouldRetryByStatus =
|
||||||
msg.includes('408') ||
|
normalizedErrorMessage.includes('408') ||
|
||||||
msg.includes('429') ||
|
normalizedErrorMessage.includes('429') ||
|
||||||
msg.includes('500') ||
|
normalizedErrorMessage.includes('500') ||
|
||||||
msg.includes('502') ||
|
normalizedErrorMessage.includes('502') ||
|
||||||
msg.includes('503') ||
|
normalizedErrorMessage.includes('503') ||
|
||||||
msg.includes('504');
|
normalizedErrorMessage.includes('504');
|
||||||
|
|
||||||
const shouldRetryByKeyword =
|
const shouldRetryByKeyword =
|
||||||
msg.includes('resource_exhausted') ||
|
normalizedErrorMessage.includes('resource_exhausted') ||
|
||||||
msg.includes('quota') ||
|
normalizedErrorMessage.includes('quota') ||
|
||||||
msg.includes('rate_limit') ||
|
normalizedErrorMessage.includes('rate_limit') ||
|
||||||
msg.includes('timeout') ||
|
normalizedErrorMessage.includes('timeout') ||
|
||||||
msg.includes('socket hang up') ||
|
normalizedErrorMessage.includes('socket hang up') ||
|
||||||
msg.includes('empty response stream') ||
|
normalizedErrorMessage.includes('empty response stream') ||
|
||||||
msg.includes('connection reset');
|
normalizedErrorMessage.includes('connection reset');
|
||||||
|
|
||||||
if (shouldRetryByStatus || shouldRetryByKeyword) {
|
if (shouldRetryByStatus || shouldRetryByKeyword) {
|
||||||
return {
|
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) {
|
if (!model) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -1510,31 +1726,27 @@ export class ProxyService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSessionKeyFromAnthropic(request: AnthropicChatRequest): string | undefined {
|
private extractAnthropicSessionKey(request: AnthropicChatRequest): string | undefined {
|
||||||
const metadata = request.metadata;
|
const metadata = request.metadata;
|
||||||
const sessionCandidate =
|
const sessionCandidate =
|
||||||
metadata?.session_id ?? metadata?.sessionId ?? metadata?.user_id ?? metadata?.userId;
|
metadata?.session_id ?? metadata?.sessionId ?? metadata?.user_id ?? metadata?.userId;
|
||||||
if (typeof sessionCandidate !== 'string' || sessionCandidate.trim() === '') {
|
if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return `anthropic:${sessionCandidate.trim()}`;
|
return `anthropic:${sessionCandidate.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSessionKeyFromOpenAI(request: OpenAIChatRequest): string | undefined {
|
private extractOpenAISessionKey(request: OpenAIChatRequest): string | undefined {
|
||||||
const extra = request.extra;
|
const extra = request.extra;
|
||||||
const sessionCandidate =
|
const sessionCandidate =
|
||||||
extra?.session_id ?? extra?.sessionId ?? extra?.user_id ?? extra?.userId;
|
extra?.session_id ?? extra?.sessionId ?? extra?.user_id ?? extra?.userId;
|
||||||
if (typeof sessionCandidate !== 'string' || sessionCandidate.trim() === '') {
|
if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return `openai:${sessionCandidate.trim()}`;
|
return `openai:${sessionCandidate.trim()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isGeminiPart(value: unknown): value is InternalGeminiPart {
|
private isGeminiPart(value: unknown): value is InternalGeminiPart {
|
||||||
return this.isRecord(value);
|
return isPlainObject(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { isEmpty, isNumber, isObjectLike, isString } from 'lodash-es';
|
||||||
|
|
||||||
|
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 (!isObjectLike(parsed)) {
|
||||||
|
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 (!isEmpty(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 = !isEmpty(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 (!isString(detail.reason) || isEmpty(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 (isString(detail.retryDelay) && !isEmpty(detail.retryDelay.trim())) {
|
||||||
|
const parsedDelay = parseDurationToSeconds(detail.retryDelay);
|
||||||
|
if (parsedDelay !== null) {
|
||||||
|
return parsedDelay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isString(detail.metadata?.retryDelay) &&
|
||||||
|
!isEmpty(detail.metadata.retryDelay.trim())
|
||||||
|
) {
|
||||||
|
const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay);
|
||||||
|
if (parsedDelay !== null) {
|
||||||
|
return parsedDelay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isString(detail.metadata?.quotaResetDelay) &&
|
||||||
|
!isEmpty(detail.metadata.quotaResetDelay.trim())
|
||||||
|
) {
|
||||||
|
const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay);
|
||||||
|
if (parsedDelay !== null) {
|
||||||
|
return parsedDelay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfter = parsedBody?.error?.retry_after;
|
||||||
|
if (isNumber(retryAfter) && 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,223 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { isString } from 'lodash-es';
|
||||||
|
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 (!isString(value)) {
|
||||||
|
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 (isString(response.data)) {
|
||||||
|
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,59 @@
|
|||||||
|
import { Notification } from 'electron';
|
||||||
import { CloudAccountRepo } from '../ipc/database/cloudHandler';
|
import { CloudAccountRepo } from '../ipc/database/cloudHandler';
|
||||||
import { GoogleAPIService } from './GoogleAPIService';
|
import { GoogleAPIService } from './GoogleAPIService';
|
||||||
import { AutoSwitchService } from './AutoSwitchService';
|
import { AutoSwitchService } from './AutoSwitchService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { classifyAccountStatusFromError } from '../utils/account-status';
|
||||||
|
|
||||||
|
type CloudMonitorLanguage = 'en' | 'zh-CN' | 'ru' | 'vi';
|
||||||
|
|
||||||
|
const CLOUD_MONITOR_NOTIFICATION_TEXT: Record<
|
||||||
|
CloudMonitorLanguage,
|
||||||
|
{
|
||||||
|
lowQuotaTitle: string;
|
||||||
|
lowQuotaBody: (email: string, models: string) => string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
en: {
|
||||||
|
lowQuotaTitle: 'Low Quota Alert',
|
||||||
|
lowQuotaBody: (email, models) => `${email}: ${models} are low on quota`,
|
||||||
|
},
|
||||||
|
'zh-CN': {
|
||||||
|
lowQuotaTitle: '额度不足提醒',
|
||||||
|
lowQuotaBody: (email, models) => `${email}:${models} 的额度较低`,
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
lowQuotaTitle: 'Предупреждение о низкой квоте',
|
||||||
|
lowQuotaBody: (email, models) => `${email}: низкая квота у ${models}`,
|
||||||
|
},
|
||||||
|
vi: {
|
||||||
|
lowQuotaTitle: 'Cảnh báo quota thấp',
|
||||||
|
lowQuotaBody: (email, models) => `${email}: ${models} đang có quota thấp`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCloudMonitorLanguage(language: string | null | undefined): CloudMonitorLanguage {
|
||||||
|
const normalizedLanguage = language?.toLowerCase() ?? 'en';
|
||||||
|
if (normalizedLanguage.startsWith('zh')) {
|
||||||
|
return 'zh-CN';
|
||||||
|
}
|
||||||
|
if (normalizedLanguage.startsWith('ru')) {
|
||||||
|
return 'ru';
|
||||||
|
}
|
||||||
|
if (normalizedLanguage.startsWith('vi')) {
|
||||||
|
return 'vi';
|
||||||
|
}
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export class CloudMonitorService {
|
||||||
private static intervalId: NodeJS.Timeout | null = null;
|
private static intervalId: NodeJS.Timeout | null = null;
|
||||||
@@ -101,29 +153,103 @@ export class CloudMonitorService {
|
|||||||
let accessToken = account.token.access_token;
|
let accessToken = account.token.access_token;
|
||||||
if (account.token.expiry_timestamp < now + 600) {
|
if (account.token.expiry_timestamp < now + 600) {
|
||||||
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
logger.info(`Monitor: Refreshing token for ${account.email}`);
|
||||||
const newToken = await GoogleAPIService.refreshAccessToken(account.token.refresh_token);
|
try {
|
||||||
account.token.access_token = newToken.access_token;
|
const newToken = await GoogleAPIService.refreshAccessToken(
|
||||||
account.token.expires_in = newToken.expires_in;
|
account.token.refresh_token,
|
||||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
account.proxy_url,
|
||||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
account.token.oauth_client_key,
|
||||||
accessToken = newToken.access_token;
|
);
|
||||||
|
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));
|
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
|
// 3. Update DB
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||||
await CloudAccountRepo.updateLastUsed(account.id);
|
await CloudAccountRepo.updateLastUsed(account.id);
|
||||||
|
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Monitor: Failed to update ${account.email}`, 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);
|
||||||
|
const notificationLanguage = getCloudMonitorLanguage(
|
||||||
|
CloudAccountRepo.getSetting<string>('language', 'en'),
|
||||||
|
);
|
||||||
|
const notificationText = CLOUD_MONITOR_NOTIFICATION_TEXT[notificationLanguage];
|
||||||
|
|
||||||
|
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: notificationText.lowQuotaTitle,
|
||||||
|
body: notificationText.lowQuotaBody(account.email, lowQuotaModels.join(', ')),
|
||||||
|
silent: false,
|
||||||
|
}).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Check for Auto-Switch
|
||||||
await AutoSwitchService.checkAndSwitchIfNeeded();
|
await AutoSwitchService.checkAndSwitchIfNeeded();
|
||||||
} finally {
|
} finally {
|
||||||
this.isPolling = false;
|
this.isPolling = false;
|
||||||
|
|||||||
+791
-133
File diff suppressed because it is too large.
Load diff
@@ -79,7 +79,7 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
|||||||
const requestWithTools = {
|
const requestWithTools = {
|
||||||
...BASE_REQUEST,
|
...BASE_REQUEST,
|
||||||
// Maps to a gemini-3 model potentially
|
// 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
|
tools: [{ name: 'get_weather' }], // Mock tool structure
|
||||||
thinking: { type: 'enabled', budget_tokens: 1000 },
|
thinking: { type: 'enabled', budget_tokens: 1000 },
|
||||||
} as any;
|
} as any;
|
||||||
@@ -96,7 +96,7 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
|||||||
|
|
||||||
const requestWithTools = {
|
const requestWithTools = {
|
||||||
...BASE_REQUEST,
|
...BASE_REQUEST,
|
||||||
model: 'gemini-3-pro-preview',
|
model: 'gemini-3.1-pro-preview',
|
||||||
tools: [{ name: 'get_weather' }],
|
tools: [{ name: 'get_weather' }],
|
||||||
thinking: { type: 'enabled', budget_tokens: 1000 },
|
thinking: { type: 'enabled', budget_tokens: 1000 },
|
||||||
} as any;
|
} as any;
|
||||||
@@ -110,7 +110,7 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
|||||||
it('should KEEP thinking mode if NO function calls exist (pure thinking)', () => {
|
it('should KEEP thinking mode if NO function calls exist (pure thinking)', () => {
|
||||||
const requestPureThinking = {
|
const requestPureThinking = {
|
||||||
...BASE_REQUEST,
|
...BASE_REQUEST,
|
||||||
model: 'gemini-3-pro-preview',
|
model: 'gemini-3.1-pro-preview',
|
||||||
// No tools
|
// No tools
|
||||||
thinking: { type: 'enabled', budget_tokens: 1000 },
|
thinking: { type: 'enabled', budget_tokens: 1000 },
|
||||||
} as any;
|
} as any;
|
||||||
@@ -120,5 +120,60 @@ describe('Antigravity Core Features (Business Logic)', () => {
|
|||||||
// Should keep thinking config
|
// Should keep thinking config
|
||||||
expect(result.request.generationConfig?.thinkingConfig).toBeDefined();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -6,17 +6,18 @@ const injectCloudAccountsFailureScript = `
|
|||||||
(() => {
|
(() => {
|
||||||
const START_ORPC_SERVER = 'start-orpc-server';
|
const START_ORPC_SERVER = 'start-orpc-server';
|
||||||
const originalPostMessage = window.postMessage.bind(window);
|
const originalPostMessage = window.postMessage.bind(window);
|
||||||
|
const isStringValue = (value) => Object.prototype.toString.call(value) === '[object String]';
|
||||||
|
|
||||||
window.postMessage = function (message, targetOrigin, transfer) {
|
window.postMessage = function (message, targetOrigin, transfer) {
|
||||||
if (message === START_ORPC_SERVER && Array.isArray(transfer) && transfer[0]) {
|
if (message === START_ORPC_SERVER && Array.isArray(transfer) && transfer[0]) {
|
||||||
const serverPort = transfer[0];
|
const serverPort = transfer[0];
|
||||||
if (typeof serverPort.start === 'function') {
|
if (serverPort.start instanceof Function) {
|
||||||
serverPort.start();
|
serverPort.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
serverPort.onmessage = (event) => {
|
serverPort.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const request = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
const request = isStringValue(event.data) ? JSON.parse(event.data) : event.data;
|
||||||
const requestId = request && (request.i || request.id);
|
const requestId = request && (request.i || request.id);
|
||||||
const requestUrl = (request && request.p && request.p.u) || '';
|
const requestUrl = (request && request.p && request.p.u) || '';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { isEmpty, isString } from 'lodash-es';
|
||||||
|
|
||||||
import { transformClaudeRequestIn } from '../../lib/antigravity/ClaudeRequestMapper';
|
import { transformClaudeRequestIn } from '../../lib/antigravity/ClaudeRequestMapper';
|
||||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||||
@@ -8,6 +9,8 @@ const mockTokenManager: any = {
|
|||||||
getNextToken: async () => null,
|
getNextToken: async () => null,
|
||||||
markAsRateLimited: () => undefined,
|
markAsRateLimited: () => undefined,
|
||||||
markAsForbidden: () => undefined,
|
markAsForbidden: () => undefined,
|
||||||
|
markFromUpstreamError: () => undefined,
|
||||||
|
recordParityError: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockGeminiClient: any = {
|
const mockGeminiClient: any = {
|
||||||
@@ -32,7 +35,7 @@ class TestableProxyService extends ProxyService {
|
|||||||
|
|
||||||
function getEnv(name: string, fallback?: string): string {
|
function getEnv(name: string, fallback?: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (value && value.trim() !== '') {
|
if (isString(value) && !isEmpty(value.trim())) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
if (fallback !== undefined) {
|
if (fallback !== undefined) {
|
||||||
@@ -44,7 +47,7 @@ function getEnv(name: string, fallback?: string): string {
|
|||||||
function getEnvFromList(names: string[], fallback?: string): string {
|
function getEnvFromList(names: string[], fallback?: string): string {
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (value && value.trim() !== '') {
|
if (isString(value) && !isEmpty(value.trim())) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +61,7 @@ function getLiveHeaders(apiKey?: string): Record<string, string> {
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
if (apiKey && apiKey.trim() !== '') {
|
if (isString(apiKey) && !isEmpty(apiKey.trim())) {
|
||||||
headers.Authorization = apiKey.startsWith('Bearer ') ? apiKey : `Bearer ${apiKey}`;
|
headers.Authorization = apiKey.startsWith('Bearer ') ? apiKey : `Bearer ${apiKey}`;
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
@@ -225,7 +228,11 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
|||||||
await realTokenManager.onModuleInit();
|
await realTokenManager.onModuleInit();
|
||||||
|
|
||||||
const tokenManagerProxy = {
|
const tokenManagerProxy = {
|
||||||
getNextToken: async (options?: { sessionKey?: string; excludeAccountIds?: string[] }) => {
|
getNextToken: async (options?: {
|
||||||
|
sessionKey?: string;
|
||||||
|
excludeAccountIds?: string[];
|
||||||
|
model?: string;
|
||||||
|
}) => {
|
||||||
observedGetNextTokenOptions = options ?? null;
|
observedGetNextTokenOptions = options ?? null;
|
||||||
selectedToken = await realTokenManager.getNextToken(options);
|
selectedToken = await realTokenManager.getNextToken(options);
|
||||||
return selectedToken;
|
return selectedToken;
|
||||||
@@ -234,6 +241,14 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
|||||||
realTokenManager.markAsRateLimited(accountIdOrEmail),
|
realTokenManager.markAsRateLimited(accountIdOrEmail),
|
||||||
markAsForbidden: (accountIdOrEmail: string) =>
|
markAsForbidden: (accountIdOrEmail: string) =>
|
||||||
realTokenManager.markAsForbidden(accountIdOrEmail),
|
realTokenManager.markAsForbidden(accountIdOrEmail),
|
||||||
|
markFromUpstreamError: (args: {
|
||||||
|
accountIdOrEmail: string;
|
||||||
|
status?: number;
|
||||||
|
retryAfter?: string;
|
||||||
|
body?: string;
|
||||||
|
model?: string;
|
||||||
|
}) => realTokenManager.markFromUpstreamError(args),
|
||||||
|
recordParityError: () => realTokenManager.recordParityError(),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -257,7 +272,7 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<vo
|
|||||||
const captured = capturedBody as Record<string, unknown>;
|
const captured = capturedBody as Record<string, unknown>;
|
||||||
const tokenProjectId = selectedToken?.token?.project_id;
|
const tokenProjectId = selectedToken?.token?.project_id;
|
||||||
const normalizedTokenProjectId =
|
const normalizedTokenProjectId =
|
||||||
typeof tokenProjectId === 'string' && tokenProjectId.trim() !== ''
|
isString(tokenProjectId) && !isEmpty(tokenProjectId.trim())
|
||||||
? tokenProjectId.trim()
|
? tokenProjectId.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('CloudMonitorService', () => {
|
|||||||
await pollPromise;
|
await pollPromise;
|
||||||
|
|
||||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
|
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.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
|
||||||
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
||||||
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
||||||
@@ -93,9 +93,13 @@ describe('CloudMonitorService', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
await pollPromise;
|
await pollPromise;
|
||||||
|
|
||||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
|
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith(
|
||||||
|
'ref_token',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
||||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
|
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleAppFocus (Smart Refresh)', () => {
|
describe('handleAppFocus (Smart Refresh)', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import { ProtobufUtils } from '../../utils/protobuf';
|
import { ProtobufUtils } from '../../utils/protobuf';
|
||||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||||
import type { UserInfo } from '../../services/GoogleAPIService';
|
import type { UserInfo } from '../../services/GoogleAPIService';
|
||||||
|
import { toSyncLocalAccountORPCError } from '../../ipc/cloud/router';
|
||||||
|
|
||||||
let mockData: Record<string, string>;
|
let mockData: Record<string, string>;
|
||||||
let busyOnFirstGet = false;
|
let busyOnFirstGet = false;
|
||||||
@@ -168,6 +169,34 @@ describe('CloudAccountRepo.syncFromIDE', () => {
|
|||||||
expect(account?.email).toBe('[email protected]');
|
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 () => {
|
it('should fall back to old oauth token when unified is missing', async () => {
|
||||||
const accessToken = 'access-old';
|
const accessToken = 'access-old';
|
||||||
const refreshToken = 'refresh-old';
|
const refreshToken = 'refresh-old';
|
||||||
@@ -191,6 +220,191 @@ describe('CloudAccountRepo.syncFromIDE', () => {
|
|||||||
expect(account?.email).toBe('[email protected]');
|
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 () => {
|
it('should retry when sqlite is busy', async () => {
|
||||||
busyOnFirstGet = true;
|
busyOnFirstGet = true;
|
||||||
const accessToken = 'access-retry';
|
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', () => {
|
describe('cloud switch fail-fast path', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -386,3 +614,172 @@ describe('cloud switch fail-fast path', () => {
|
|||||||
expect(recordSwitchSuccessMock).not.toHaveBeenCalled();
|
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({
|
expect.objectContaining({
|
||||||
models: expect.arrayContaining([
|
models: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
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: '',
|
description: '',
|
||||||
inputTokenLimit: 128000,
|
inputTokenLimit: 128000,
|
||||||
outputTokenLimit: 8192,
|
outputTokenLimit: 8192,
|
||||||
@@ -74,13 +81,13 @@ describe('GeminiController Integration', () => {
|
|||||||
const reply = createReplyMock();
|
const reply = createReplyMock();
|
||||||
|
|
||||||
await controller.modelAction(
|
await controller.modelAction(
|
||||||
'models/gemini-2.5-flash:generateContent',
|
'models/gemini-3.1-pro-high:generateContent',
|
||||||
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||||
reply as any,
|
reply as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledWith(
|
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledWith(
|
||||||
'models/gemini-2.5-flash',
|
'models/gemini-3.1-pro-high',
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
expect(reply.status).toHaveBeenCalledWith(200);
|
expect(reply.status).toHaveBeenCalledWith(200);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { isString } from 'lodash-es';
|
||||||
|
|
||||||
// Mock module to test timeout behavior
|
// Mock module to test timeout behavior
|
||||||
describe('GoogleAPIService Timeout', () => {
|
describe('GoogleAPIService Timeout', () => {
|
||||||
@@ -80,3 +81,340 @@ describe('GoogleAPIService Timeout', () => {
|
|||||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
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 (isString(originalOauthClientsEnv)) {
|
||||||
|
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isString(originalActiveOauthClientEnv)) {
|
||||||
|
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,
|
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 () => {
|
it('supports image generations endpoint', async () => {
|
||||||
const proxyService = {
|
const proxyService = {
|
||||||
handleChatCompletions: vi.fn().mockResolvedValue({
|
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const mockTokenManager = {
|
|||||||
getNextToken: vi.fn(),
|
getNextToken: vi.fn(),
|
||||||
markAsRateLimited: vi.fn(),
|
markAsRateLimited: vi.fn(),
|
||||||
markAsForbidden: vi.fn(),
|
markAsForbidden: vi.fn(),
|
||||||
|
markFromUpstreamError: vi.fn(),
|
||||||
|
recordParityError: vi.fn(),
|
||||||
};
|
};
|
||||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||||
|
|
||||||
@@ -95,4 +97,5 @@ describe('Proxy Parity Fixtures', () => {
|
|||||||
expect(output).toContain(token);
|
expect(output).toContain(token);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,11 @@ const mockTokenManager = {
|
|||||||
getNextToken: vi.fn(),
|
getNextToken: vi.fn(),
|
||||||
markAsRateLimited: vi.fn(),
|
markAsRateLimited: vi.fn(),
|
||||||
markAsForbidden: 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() };
|
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||||
|
|
||||||
@@ -27,7 +32,7 @@ class TestableProxyService extends ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public testModelHeaders(model: string): Record<string, string> {
|
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', () => {
|
it('classifies retry matrix consistently', () => {
|
||||||
const service = new TestableProxyService();
|
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({
|
expect(classify('401 unauthorized token')).toEqual({
|
||||||
retry: true,
|
retry: true,
|
||||||
@@ -401,6 +406,22 @@ describe('ProxyService Empty Stream Retry Logic', () => {
|
|||||||
expect(internalPayload).not.toHaveProperty('sessionId');
|
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 () => {
|
it('strips non-parity Gemini usage metadata fields', async () => {
|
||||||
const service = new TestableProxyService();
|
const service = new TestableProxyService();
|
||||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||||
@@ -631,4 +652,43 @@ describe('ProxyService Protocol Parity Fixtures', () => {
|
|||||||
expect(output).toContain('"content":"final answer"');
|
expect(output).toContain('"content":"final answer"');
|
||||||
expect(output).toContain('data: [DONE]');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||||
|
import { isUndefined } from 'lodash-es';
|
||||||
import { expect, vi } from 'vitest';
|
import { expect, vi } from 'vitest';
|
||||||
|
|
||||||
expect.extend(matchers);
|
expect.extend(matchers);
|
||||||
|
|
||||||
// Mock window and localStorage for Node environment
|
// Mock window and localStorage for Node environment
|
||||||
if (typeof window === 'undefined') {
|
if (isUndefined((globalThis as { window?: unknown }).window)) {
|
||||||
global.window = {
|
global.window = {
|
||||||
matchMedia: vi.fn().mockImplementation((query) => ({
|
matchMedia: vi.fn().mockImplementation((query) => ({
|
||||||
matches: false,
|
matches: false,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ describe('decodeSignature', () => {
|
|||||||
expect(decodeSignature(base64)).toBe(original);
|
expect(decodeSignature(base64)).toBe(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should decode UTF-8 Chinese signature', () => {
|
it('should decode UTF-8 signature text', () => {
|
||||||
const original = '思考签名-测试';
|
const original = 'thinking-signature-test-utf8';
|
||||||
const base64 = Buffer.from(original).toString('base64');
|
const base64 = Buffer.from(original).toString('base64');
|
||||||
expect(decodeSignature(base64)).toBe(original);
|
expect(decodeSignature(base64)).toBe(original);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper';
|
import { PartProcessor, StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper';
|
||||||
|
|
||||||
describe('StreamingState', () => {
|
describe('StreamingState', () => {
|
||||||
let state: StreamingState;
|
let state: StreamingState;
|
||||||
@@ -58,4 +58,48 @@ describe('StreamingState', () => {
|
|||||||
expect(state.getErrorCount()).toBe(2);
|
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;
|
token_type: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
|
oauth_client_key?: string;
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
upstream_proxy_url?: 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 {
|
export interface CloudQuotaData {
|
||||||
models: Record<
|
models: Record<string, CloudQuotaModelInfo>;
|
||||||
string,
|
model_forwarding_rules?: Record<string, string>;
|
||||||
{
|
subscription_tier?: string;
|
||||||
percentage: number;
|
is_forbidden?: boolean;
|
||||||
resetTime: string;
|
isForbidden?: boolean;
|
||||||
}
|
ai_credits?: { credits: number; expiryDate: string };
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloudAccount {
|
export interface CloudAccount {
|
||||||
@@ -41,7 +55,9 @@ export interface CloudAccount {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
last_used: number; // Unix timestamp
|
last_used: number; // Unix timestamp
|
||||||
status?: 'active' | 'rate_limited' | 'expired';
|
status?: 'active' | 'rate_limited' | 'expired';
|
||||||
|
status_reason?: string;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
proxy_url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zod Schemas
|
// Zod Schemas
|
||||||
@@ -53,18 +69,32 @@ export const CloudTokenDataSchema = z.object({
|
|||||||
token_type: z.string(),
|
token_type: z.string(),
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
project_id: z.string().optional(),
|
project_id: z.string().optional(),
|
||||||
|
oauth_client_key: z.string().optional(),
|
||||||
session_id: z.string().optional(),
|
session_id: z.string().optional(),
|
||||||
upstream_proxy_url: 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({
|
export const CloudQuotaDataSchema = z.object({
|
||||||
models: z.record(
|
models: z.record(z.string(), CloudQuotaModelInfoSchema),
|
||||||
z.string(),
|
model_forwarding_rules: z.record(z.string(), z.string()).optional(),
|
||||||
z.object({
|
subscription_tier: z.string().optional(),
|
||||||
percentage: z.number(),
|
is_forbidden: z.boolean().optional(),
|
||||||
resetTime: z.string(),
|
isForbidden: z.boolean().optional(),
|
||||||
}),
|
ai_credits: z.object({ credits: z.number(), expiryDate: z.string() }).optional(),
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CloudAccountSchema = z.object({
|
export const CloudAccountSchema = z.object({
|
||||||
@@ -80,5 +110,29 @@ export const CloudAccountSchema = z.object({
|
|||||||
created_at: z.number(),
|
created_at: z.number(),
|
||||||
last_used: z.number(),
|
last_used: z.number(),
|
||||||
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
status: z.enum(['active', 'rate_limited', 'expired']).optional(),
|
||||||
|
status_reason: z.string().optional(),
|
||||||
is_active: z.boolean().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({
|
export const ProxyConfigSchema = z.object({
|
||||||
enabled: z.boolean(), // 是否启用
|
enabled: z.boolean(),
|
||||||
port: z.number(), // 监听端口
|
port: z.number(),
|
||||||
api_key: z.string(), // API 密钥 (Also used for Admin Mode auth)
|
api_key: z.string(),
|
||||||
auto_start: z.boolean(), // 是否自动启动
|
auto_start: z.boolean(),
|
||||||
backend_canary_enabled: z.boolean().default(true),
|
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({}),
|
custom_mapping: z.record(z.string(), z.string()).default({}),
|
||||||
anthropic_mapping: z.record(z.string(), z.string()), // 映射表
|
anthropic_mapping: z.record(z.string(), z.string()), // Mapping table
|
||||||
request_timeout: z.number().default(120), // 超时秒数
|
request_timeout: z.number().default(120), // Timeout in seconds
|
||||||
upstream_proxy: UpstreamProxyConfigSchema,
|
upstream_proxy: UpstreamProxyConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,10 +37,15 @@ export const AppConfigSchema = z.object({
|
|||||||
auto_startup: z.boolean(),
|
auto_startup: z.boolean(),
|
||||||
error_reporting_enabled: z.boolean(),
|
error_reporting_enabled: z.boolean(),
|
||||||
privacy_consent_asked: z.boolean().optional().default(false), // Optional for backward compatibility
|
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
|
model_visibility: z.record(z.string(), z.boolean()).default({}), // Model visibility preferences
|
||||||
provider_groupings_enabled: z.boolean().default(false), // Enable provider groupings UI
|
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,
|
proxy: ProxyConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +55,7 @@ export type AppConfig = z.infer<typeof AppConfigSchema>;
|
|||||||
|
|
||||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
theme: 'system',
|
theme: 'dark',
|
||||||
auto_refresh: false,
|
auto_refresh: false,
|
||||||
refresh_interval: 15,
|
refresh_interval: 15,
|
||||||
auto_sync: false,
|
auto_sync: false,
|
||||||
@@ -52,12 +67,25 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
|
|||||||
model_visibility: {}, // Model visibility preferences
|
model_visibility: {}, // Model visibility preferences
|
||||||
provider_groupings_enabled: false, // Enable provider groupings UI
|
provider_groupings_enabled: false, // Enable provider groupings UI
|
||||||
grid_layout: 'auto' as const, // Account card grid layout
|
grid_layout: 'auto' as const, // Account card grid layout
|
||||||
|
account_sort: 'recently-used' as const,
|
||||||
|
quota_alert_enabled: false,
|
||||||
|
quota_alert_threshold: 20,
|
||||||
proxy: {
|
proxy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
port: 8045,
|
port: 8045,
|
||||||
api_key: '', // Generated dynamically if default needed
|
api_key: '', // Generated dynamically if default needed
|
||||||
auto_start: false,
|
auto_start: false,
|
||||||
backend_canary_enabled: true,
|
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: {},
|
custom_mapping: {},
|
||||||
anthropic_mapping: {},
|
anthropic_mapping: {},
|
||||||
request_timeout: 120,
|
request_timeout: 120,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { isString } from 'lodash-es';
|
||||||
|
|
||||||
|
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 (isString(error)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
import { isObjectLike } from 'lodash-es';
|
||||||
|
|
||||||
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
|
const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE';
|
||||||
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
|
const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION';
|
||||||
@@ -60,7 +61,7 @@ export function getLocalizedErrorMessage(error: unknown, t: TFunction): string {
|
|||||||
return rawMessage;
|
return rawMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && typeof error === 'object' && 'message' in error) {
|
if (isObjectLike(error)) {
|
||||||
const rawMessage = String((error as { message?: unknown }).message ?? '');
|
const rawMessage = String((error as { message?: unknown }).message ?? '');
|
||||||
const [code, hint] = rawMessage.split('|');
|
const [code, hint] = rawMessage.split('|');
|
||||||
if (code === KEYCHAIN_ERROR_CODE) {
|
if (code === KEYCHAIN_ERROR_CODE) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
|
export function shouldAutoSubmitGoogleAuthCode(params: {
|
||||||
|
authCode: string;
|
||||||
|
isAddDialogOpen: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
lastSubmittedAuthCode: string | null;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
!isEmpty(params.authCode.trim()) &&
|
||||||
|
params.isAddDialogOpen &&
|
||||||
|
!params.isPending &&
|
||||||
|
params.authCode !== params.lastSubmittedAuthCode
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru';
|
export type InstallNoticeLanguage = 'zh-CN' | 'en' | 'ru' | 'vi';
|
||||||
|
|
||||||
const installNoticeText: Record<
|
const installNoticeText: Record<
|
||||||
InstallNoticeLanguage,
|
InstallNoticeLanguage,
|
||||||
@@ -32,6 +32,13 @@ const installNoticeText: Record<
|
|||||||
detailPrefix: 'Папка установки: ',
|
detailPrefix: 'Папка установки: ',
|
||||||
buttons: ['Открыть папку', 'Понятно'],
|
buttons: ['Открыть папку', 'Понятно'],
|
||||||
},
|
},
|
||||||
|
vi: {
|
||||||
|
title: 'Hãy mở ứng dụng từ menu Start',
|
||||||
|
message:
|
||||||
|
'Ứng dụng đang được chạy từ vị trí không phải thư mục cài đặt. Để tự động cập nhật hoạt động đúng, hãy mở ứng dụng từ menu Start hoặc biểu tượng ngoài màn hình. Nếu chưa có lối tắt, hãy chạy lại bộ cài.',
|
||||||
|
detailPrefix: 'Thư mục cài đặt: ',
|
||||||
|
buttons: ['Mở thư mục cài đặt', 'Đã hiểu'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveInstallNoticeLanguage({
|
export function resolveInstallNoticeLanguage({
|
||||||
@@ -55,6 +62,10 @@ export function resolveInstallNoticeLanguage({
|
|||||||
return 'ru';
|
return 'ru';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('vi')) {
|
||||||
|
return 'vi';
|
||||||
|
}
|
||||||
|
|
||||||
return 'en';
|
return 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { isObjectLike } from 'lodash-es';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import DailyRotateFile from 'winston-daily-rotate-file';
|
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||||
import { getAgentDir } from './paths';
|
import { getAgentDir } from './paths';
|
||||||
@@ -42,7 +43,7 @@ function safeStringify(obj: unknown): string {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Handle circular references
|
// Handle circular references
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (isObjectLike(value)) {
|
||||||
if (seen.has(value)) {
|
if (seen.has(value)) {
|
||||||
return '[Circular]';
|
return '[Circular]';
|
||||||
}
|
}
|
||||||
@@ -143,7 +144,7 @@ class Logger {
|
|||||||
|
|
||||||
private formatArgs(args: unknown[]): string {
|
private formatArgs(args: unknown[]): string {
|
||||||
return args
|
return args
|
||||||
.map((arg) => (typeof arg === 'object' ? safeStringify(arg) : String(arg)))
|
.map((arg) => (isObjectLike(arg) ? safeStringify(arg) : String(arg)))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-4
@@ -236,11 +236,36 @@ export function getAntigravityExecutablePath(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to default
|
// No known path found; return empty string (caller must handle missing binary)
|
||||||
return possiblePaths[0];
|
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:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
+205
-34
@@ -1,4 +1,15 @@
|
|||||||
export class ProtobufUtils {
|
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 {
|
static encodeVarint(value: number): Uint8Array {
|
||||||
// Note: Javascript bitwise operators treat operands as 32-bit integers.
|
// Note: Javascript bitwise operators treat operands as 32-bit integers.
|
||||||
// However, for typical token lengths and small field IDs, this is fine.
|
// However, for typical token lengths and small field IDs, this is fine.
|
||||||
@@ -111,6 +122,13 @@ export class ProtobufUtils {
|
|||||||
return this.encodeLenDelimField(fieldNum, bytes);
|
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)
|
// Create timestamp field (Field 4 -> Field 1 varint)
|
||||||
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
static createTimestampField(fieldNum: number, seconds: number): Uint8Array {
|
||||||
// Timestamp message format: Field 1 (seconds) as varint
|
// Timestamp message format: Field 1 (seconds) as varint
|
||||||
@@ -217,7 +235,12 @@ export class ProtobufUtils {
|
|||||||
return result;
|
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 field1 = this.encodeStringField(1, accessToken);
|
||||||
const field2 = this.encodeStringField(2, 'Bearer');
|
const field2 = this.encodeStringField(2, 'Bearer');
|
||||||
const field3 = this.encodeStringField(3, refreshToken);
|
const field3 = this.encodeStringField(3, refreshToken);
|
||||||
@@ -230,12 +253,16 @@ export class ProtobufUtils {
|
|||||||
timestampMsg.set(secondsBytes, tagBytes.length);
|
timestampMsg.set(secondsBytes, tagBytes.length);
|
||||||
|
|
||||||
const field4 = this.encodeLenDelimField(4, timestampMsg);
|
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(field1, 0);
|
||||||
combined.set(field2, field1.length);
|
combined.set(field2, field1.length);
|
||||||
combined.set(field3, field1.length + field2.length);
|
combined.set(field3, field1.length + field2.length);
|
||||||
combined.set(field4, field1.length + field2.length + field3.length);
|
combined.set(field4, field1.length + field2.length + field3.length);
|
||||||
|
combined.set(field6, field1.length + field2.length + field3.length + field4.length);
|
||||||
return combined;
|
return combined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,20 +270,10 @@ export class ProtobufUtils {
|
|||||||
accessToken: string,
|
accessToken: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
expiry: number,
|
expiry: number,
|
||||||
|
isGcpTos = true,
|
||||||
): string {
|
): string {
|
||||||
const oauthInfo = this.createOAuthInfo(accessToken, refreshToken, expiry);
|
const oauthInfo = this.createOAuthInfo(accessToken, refreshToken, expiry, isGcpTos);
|
||||||
const oauthInfoB64 = Buffer.from(oauthInfo).toString('base64');
|
return this.createUnifiedStateEntry('oauthTokenInfoSentinelKey', oauthInfo);
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static extractOAuthTokenInfoFromOAuthInfo(
|
static extractOAuthTokenInfoFromOAuthInfo(
|
||||||
@@ -277,29 +294,183 @@ export class ProtobufUtils {
|
|||||||
static extractOAuthTokenInfoFromUnifiedState(
|
static extractOAuthTokenInfoFromUnifiedState(
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
): { accessToken: string; refreshToken: string } | null {
|
): { accessToken: string; refreshToken: string } | null {
|
||||||
const inner = this.getField(data, 1);
|
let decoded: { sentinelKey: string; payload: Uint8Array };
|
||||||
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;
|
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null;
|
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.
|
* and calculating aggregated statistics.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { flatMap, groupBy, keys, map, min, sortBy, sumBy } from 'lodash-es';
|
||||||
|
import { roundQuotaPercentage } from '@/utils/quota-display';
|
||||||
|
|
||||||
export interface ProviderInfo {
|
export interface ProviderInfo {
|
||||||
name: string;
|
name: string;
|
||||||
company: string;
|
company: string;
|
||||||
@@ -29,16 +32,25 @@ export const PROVIDER_REGISTRY: Record<string, ProviderInfo> = {
|
|||||||
|
|
||||||
export type ProviderKey = keyof typeof PROVIDER_REGISTRY;
|
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.
|
* Detect the provider key for a given model name based on prefix matching.
|
||||||
*/
|
*/
|
||||||
export function detectProvider(modelName: string): ProviderKey {
|
export function detectProvider(modelName: string): ProviderKey {
|
||||||
for (const prefix of Object.keys(PROVIDER_REGISTRY)) {
|
const matchedPrefix = keys(PROVIDER_REGISTRY).find(
|
||||||
if (prefix !== 'others' && modelName.startsWith(prefix)) {
|
(prefix) => prefix !== 'others' && modelName.startsWith(prefix),
|
||||||
return prefix;
|
);
|
||||||
}
|
|
||||||
|
if (!matchedPrefix) {
|
||||||
|
return 'others';
|
||||||
}
|
}
|
||||||
return 'others';
|
|
||||||
|
return matchedPrefix as ProviderKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +76,58 @@ export interface ProviderStats {
|
|||||||
earliestReset: string | null;
|
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.
|
* 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);
|
const visibleModels = models.filter((m) => visibilitySettings[m.id] !== false);
|
||||||
|
|
||||||
if (visibleModels.length === 0) {
|
if (visibleModels.length === 0) {
|
||||||
return {
|
return buildProviderStats(providerKey, models, [], null);
|
||||||
providerKey,
|
|
||||||
providerInfo: PROVIDER_REGISTRY[providerKey],
|
|
||||||
models,
|
|
||||||
visibleModels: [],
|
|
||||||
avgPercentage: 0,
|
|
||||||
earliestReset: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgPercentage =
|
const resetTimes = map(visibleModels, (model) => parseResetTimestamp(model.resetTime)).filter(
|
||||||
visibleModels.reduce((sum, m) => sum + m.percentage, 0) / visibleModels.length;
|
(timestamp): timestamp is number => timestamp !== null,
|
||||||
|
);
|
||||||
const resetTimes = visibleModels
|
|
||||||
.map((m) => m.resetTime)
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((time) => new Date(time).getTime())
|
|
||||||
.filter((t) => !Number.isNaN(t));
|
|
||||||
|
|
||||||
|
const earliestTimestamp = min(resetTimes);
|
||||||
const earliestReset =
|
const earliestReset =
|
||||||
resetTimes.length > 0 ? new Date(Math.min(...resetTimes)).toISOString() : null;
|
earliestTimestamp !== undefined ? new Date(earliestTimestamp).toISOString() : null;
|
||||||
|
|
||||||
return {
|
return buildProviderStats(providerKey, models, visibleModels, earliestReset);
|
||||||
providerKey,
|
|
||||||
providerInfo: PROVIDER_REGISTRY[providerKey],
|
|
||||||
models,
|
|
||||||
visibleModels,
|
|
||||||
avgPercentage: Math.round(avgPercentage * 10) / 10,
|
|
||||||
earliestReset,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountStats {
|
export interface AccountStats {
|
||||||
@@ -122,53 +168,39 @@ export function groupModelsByProvider(
|
|||||||
models: Record<string, { percentage: number; resetTime: string }>,
|
models: Record<string, { percentage: number; resetTime: string }>,
|
||||||
visibilitySettings: Record<string, boolean>,
|
visibilitySettings: Record<string, boolean>,
|
||||||
): AccountStats {
|
): AccountStats {
|
||||||
const grouped: Record<ProviderKey, ModelQuota[]> = {};
|
const modelQuotas = map(models, (info, modelName) => ({
|
||||||
|
providerKey: detectProvider(modelName),
|
||||||
for (const [modelName, info] of Object.entries(models)) {
|
quota: {
|
||||||
const key = detectProvider(modelName);
|
|
||||||
if (!grouped[key]) {
|
|
||||||
grouped[key] = [];
|
|
||||||
}
|
|
||||||
grouped[key].push({
|
|
||||||
id: modelName,
|
id: modelName,
|
||||||
percentage: info.percentage,
|
percentage: info.percentage,
|
||||||
resetTime: info.resetTime,
|
resetTime: info.resetTime,
|
||||||
});
|
},
|
||||||
}
|
}));
|
||||||
|
|
||||||
const providers: ProviderStats[] = [];
|
const providerModelGroups = groupBy(modelQuotas, (modelQuota) => modelQuota.providerKey);
|
||||||
for (const [key, groupModels] of Object.entries(grouped)) {
|
const providerStatsList = map(providerModelGroups, (groupedQuotas, key) => {
|
||||||
providers.push(calculateProviderStats(key, groupModels, visibilitySettings));
|
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
|
// Sort: known providers first (claude-, gemini-), then others
|
||||||
const providerOrder = Object.keys(PROVIDER_REGISTRY);
|
const providerDisplayOrder = keys(PROVIDER_REGISTRY);
|
||||||
providers.sort(
|
const sortedProviders = sortBy(providerStatsList, (providerStats) =>
|
||||||
(a, b) => providerOrder.indexOf(a.providerKey) - providerOrder.indexOf(b.providerKey),
|
providerDisplayOrder.indexOf(providerStats.providerKey),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allVisibleModels = providers.flatMap((p) => p.visibleModels);
|
const allVisibleModels = flatMap(sortedProviders, (providerStats) => providerStats.visibleModels);
|
||||||
const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0);
|
const totalModels = sumBy(sortedProviders, (providerStats) => providerStats.models.length);
|
||||||
|
const overallPercentage = toRoundedAverage(allVisibleModels);
|
||||||
const overallPercentage =
|
const healthStatus = resolveAccountHealthStatus(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';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
providers,
|
providers: sortedProviders,
|
||||||
totalModels,
|
totalModels,
|
||||||
visibleModels: allVisibleModels.length,
|
visibleModels: allVisibleModels.length,
|
||||||
overallPercentage: Math.round(overallPercentage * 10) / 10,
|
overallPercentage,
|
||||||
healthStatus,
|
healthStatus,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 formatAiCreditsAmount(credits: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(credits);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
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 {
|
try {
|
||||||
const encryptedKey = await fs.readFile(keyPath);
|
|
||||||
const hexKey = safeStorage.decryptString(encryptedKey);
|
const hexKey = safeStorage.decryptString(encryptedKey);
|
||||||
|
|
||||||
if (!/^[a-f0-9]+$/i.test(hexKey) || hexKey.length !== 64) {
|
if (!/^[a-f0-9]+$/i.test(hexKey) || hexKey.length !== 64) {
|
||||||
|
logger.warn('Security: safeStorage key has invalid format');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Buffer.from(hexKey, 'hex');
|
return Buffer.from(hexKey, 'hex');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
logger.error('Security: Decryption of safeStorage key file failed. Keyring might be locked.', error);
|
||||||
return null;
|
throw error;
|
||||||
}
|
|
||||||
|
|
||||||
logger.warn('Security: Failed to read safeStorage key file', error);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,14 +176,15 @@ async function readFileFallbackKey(keyPath: string): Promise<Buffer | null> {
|
|||||||
return Buffer.from(content, 'hex');
|
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;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn('Security: Error reading fallback key file', error);
|
logger.error('Security: Error reading fallback key file', error);
|
||||||
return null;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +301,13 @@ async function generatePrimaryMasterKey(): Promise<MasterKeyState> {
|
|||||||
}
|
}
|
||||||
return { key: result.key, source: 'safeStorage' };
|
return { key: result.key, source: 'safeStorage' };
|
||||||
} catch (error) {
|
} 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);
|
logger.warn('Security: safeStorage failed, trying keytar', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isObjectLike, isString } from 'lodash-es';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keys to mask in logs to avoid exposing sensitive data.
|
* Keys to mask in logs to avoid exposing sensitive data.
|
||||||
*/
|
*/
|
||||||
@@ -39,10 +41,10 @@ function sanitizeWithSeen(obj: unknown, seen: WeakSet<object>): unknown {
|
|||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof obj === 'string') {
|
if (isString(obj)) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(obj);
|
const parsed = JSON.parse(obj);
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
if (isObjectLike(parsed)) {
|
||||||
return JSON.stringify(sanitizeWithSeen(parsed, seen));
|
return JSON.stringify(sanitizeWithSeen(parsed, seen));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -59,7 +61,7 @@ function sanitizeWithSeen(obj: unknown, seen: WeakSet<object>): unknown {
|
|||||||
return obj.map((item) => sanitizeWithSeen(item, seen));
|
return obj.map((item) => sanitizeWithSeen(item, seen));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof obj === 'object') {
|
if (isObjectLike(obj)) {
|
||||||
if (seen.has(obj)) {
|
if (seen.has(obj)) {
|
||||||
return CIRCULAR_PLACEHOLDER;
|
return CIRCULAR_PLACEHOLDER;
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ export function safeStringifyPacket(obj: unknown): string {
|
|||||||
const sanitized = sanitizeObject(obj);
|
const sanitized = sanitizeObject(obj);
|
||||||
const seen = new WeakSet();
|
const seen = new WeakSet();
|
||||||
return JSON.stringify(sanitized, (key, value) => {
|
return JSON.stringify(sanitized, (key, value) => {
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (isObjectLike(value)) {
|
||||||
if (seen.has(value)) {
|
if (seen.has(value)) {
|
||||||
return '[Circular]';
|
return '[Circular]';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
const shouldEnableSentry = mode === 'production' && Boolean(sentryAuthToken);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins:
|
plugins: shouldEnableSentry
|
||||||
shouldEnableSentry
|
? [
|
||||||
? [
|
sentryVitePlugin({
|
||||||
sentryVitePlugin({
|
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
||||||
org: process.env.SENTRY_ORG || env.SENTRY_ORG,
|
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
||||||
project: process.env.SENTRY_PROJECT || env.SENTRY_PROJECT,
|
authToken: sentryAuthToken,
|
||||||
authToken: sentryAuthToken,
|
release: {
|
||||||
release: {
|
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
||||||
name: `${process.env.npm_package_name}@${process.env.npm_package_version}`,
|
},
|
||||||
},
|
}),
|
||||||
}),
|
]
|
||||||
]
|
: [],
|
||||||
: [],
|
|
||||||
define: {
|
define: {
|
||||||
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN || env.SENTRY_DSN),
|
||||||
},
|
},
|
||||||
|
|||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user