Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed730b23b | ||
|
|
0a6a8ff4bb | ||
|
|
8e20c32117 | ||
|
|
2e8657d7a0 | ||
|
|
1ad15d6e3a | ||
|
|
291b8ac91f | ||
|
|
880b799535 | ||
|
|
cf5b669133 | ||
|
|
4b3f193bd0 | ||
|
|
fa39fb34fe | ||
|
|
b193bee03f |
No files matched your search
@@ -95,10 +95,6 @@ out/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
# Tanstack
|
||||
.tanstack/
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [0.15.5](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.4...v0.15.5) (2026-10-01)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* parse complete retry durations and preserve local IDE configuration ([b193bee](https://github.com/evandrodevbr/GeminiNexus/commit/b193bee03f1aaea5f7173e53ec6cdb787717067c))
|
||||
|
||||
### ✅ Tests
|
||||
|
||||
* mock OS exports consistently and verify LAN discovery ([fa39fb3](https://github.com/evandrodevbr/GeminiNexus/commit/fa39fb34fef662c11cd8780b562fa8008b982823))
|
||||
|
||||
## [0.15.4](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.3...v0.15.4) (2026-09-14)
|
||||
|
||||
### 📝 Documentation
|
||||
|
||||
+2
-2
@@ -201,7 +201,7 @@ _(Consulta la advertencia en la parte superior de esta página si usas Windows y
|
||||
Con la aplicación en ejecución y al menos una cuenta agregada, configura tu IDE preferido (Cursor, Windsurf, OpenCode, etc.):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Base URL: http://localhost:8045/v1
|
||||
API Key: (copiar de la página Proxy en la app)
|
||||
Model: gemini-3-flash (o cualquier modelo mapeado)
|
||||
```
|
||||
@@ -298,7 +298,7 @@ Sí, este es un aviso común para nuevas aplicaciones no firmadas. Haz clic en *
|
||||
<summary><b>P: ¿El IDE no se puede conectar al proxy?</b></summary>
|
||||
|
||||
1. Asegúrate de que el proxy esté funcionando (indicador verde en la barra de estado).
|
||||
2. Confirma que el puerto coincide con tu configuración del IDE (por defecto `10100`).
|
||||
2. Confirma que el puerto coincide con tu configuración del IDE (por defecto `8045`).
|
||||
3. Copia la API key directamente desde la página Proxy y pégala en la configuración de tu IDE.
|
||||
4. Asegúrate de tener al menos una cuenta activa en la página Cuentas.
|
||||
</details>
|
||||
|
||||
@@ -78,7 +78,7 @@ It is a personal desktop tool, not a hosted service: everything runs on your mac
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js `^20.19.0 || >=22.12.0` (Vite 8 engine range). The repository pins 22.17.1 in `.nvmrc`; this audit ran on Node 24.20.0.
|
||||
- Node.js `^20.19.0 || >=22.12.0` (Vite 8 engine range). The repository pins 22.17.1 in `.nvmrc`.
|
||||
- pnpm `10.11.0` (declared in `package.json`). With Corepack enabled, `pnpm` resolves to that version automatically.
|
||||
- Native build toolchain for `better-sqlite3` and `keytar`: on Linux, `base-devel`/`build-essential`, `python3` and `libsecret`; on macOS, Xcode Command Line Tools; on Windows, Visual Studio Build Tools with the C++ workload. `pnpm install` runs `scripts/setup-dev.js --check-only`, which reports what is missing.
|
||||
- A graphical session to run the desktop app itself (Electron).
|
||||
@@ -158,7 +158,7 @@ Routes actually mapped by the gateway (read from the running server):
|
||||
| `GET /v1beta/models`, `GET /v1beta/models/:model` | Gemini-native model listing. |
|
||||
| `POST /v1beta/models/:modelAction`, `POST /v1beta/models/:model/countTokens` | Gemini-native generate and token counting. |
|
||||
|
||||
Authentication: the gateway accepts the API key as `Authorization: Bearer <key>`, `x-api-key: <key>` or `x-goog-api-key: <key>`. If the configured key is empty, the gateway runs open, which is only sensible while it is bound to localhost.
|
||||
Authentication: the gateway accepts the API key as `Authorization: Bearer <key>`, `x-api-key: <key>` or `x-goog-api-key: <key>`. If the configured key is empty, the gateway runs open. It listens on all network interfaces (`0.0.0.0`, see `src/server/main.ts`), so always set an API key on a machine that is reachable from a LAN.
|
||||
|
||||
## Build and release
|
||||
|
||||
@@ -199,19 +199,18 @@ scripts/ dev environment setup, Windows build help
|
||||
|
||||
## Verification
|
||||
|
||||
What exists and what was run on Linux x64 (Node 24.20.0, pnpm 10.11.0):
|
||||
Checks used before merging, all run with `pnpm`:
|
||||
|
||||
| Command | Result |
|
||||
| Command | Notes |
|
||||
|---|---|
|
||||
| `pnpm install --frozen-lockfile` | exit 0 |
|
||||
| `pnpm test` | 54 test files, 499 tests, all passing |
|
||||
| `pnpm run type-check` | exit 0 |
|
||||
| `pnpm run lint` | exit 0 (327 warnings, no errors) |
|
||||
| `pnpm run format` | all files formatted |
|
||||
| `pnpm run package` | exit 0, `out/Gemini Nexus-linux-x64` |
|
||||
| `pnpm run test:e2e` | not run: Playwright drives Electron and needs a graphical session |
|
||||
| `pnpm install --frozen-lockfile` | Run it with a native toolchain, or add `--ignore-scripts` to skip compiling `better-sqlite3`/`keytar` (the unit tests mock both). |
|
||||
| `pnpm test` | Vitest suite under `src/tests/unit` and `src/tests/integration`. |
|
||||
| `pnpm run type-check` | `tsc --noEmit`. |
|
||||
| `pnpm run lint` | ESLint; reports warnings (mostly `no-explicit-any`) but no errors. |
|
||||
| `pnpm run format` | Prettier check. |
|
||||
| `pnpm run test:e2e` | Not part of CI: Playwright drives Electron and needs a graphical session. |
|
||||
|
||||
The E2E suite is also excluded from CI for the same reason.
|
||||
Electron packaging, real account switching and the E2E suite need separate validation on each platform.
|
||||
|
||||
## Current state and limitations
|
||||
|
||||
@@ -221,6 +220,8 @@ The E2E suite is also excluded from CI for the same reason.
|
||||
- UI languages are en, zh-CN, ru and vi. The default language in `DEFAULT_APP_CONFIG` is `zh-CN`.
|
||||
- Windows installers are unsigned (SmartScreen warning). macOS builds are unsigned and unnotarized.
|
||||
- The E2E suite does not run in CI.
|
||||
- `src/tests/GeminiNexusCoreFeatures.test.ts` sits outside the Vitest `include` globs, so it never runs, and several of its cases no longer match the current request mapper.
|
||||
- `response_format: { type: 'json_schema' }` sets the JSON mime type but the schema itself is not forwarded upstream (`buildGenerationConfig` in `src/lib/geminiNexus/ClaudeRequestMapper.ts`).
|
||||
- The project is mid-migration to a single license: the code inherited from AntigravityManager is CC BY-NC-SA 4.0 (non-commercial), while the new code is MIT. See the license section.
|
||||
- `pnpm install` reports `Ignored build scripts: sharp`. `sharp` is only used by `scripts/generate-assets.ts` for icon generation, and its prebuilt binaries are installed through optional dependencies.
|
||||
|
||||
|
||||
+2
-2
@@ -201,7 +201,7 @@ _(Consulte o aviso no topo desta página se você estiver usando o Windows e enc
|
||||
Com o aplicativo rodando e pelo menos uma conta adicionada, configure sua IDE preferida (Cursor, Windsurf, OpenCode, etc.):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Base URL: http://localhost:8045/v1
|
||||
API Key: (copie da página Proxy no app)
|
||||
Model: gemini-3-flash (ou qualquer modelo mapeado)
|
||||
```
|
||||
@@ -319,7 +319,7 @@ Sim, este é um aviso comum para novos aplicativos não assinados. Clique em **"
|
||||
<summary><b>P: A IDE não consegue conectar ao proxy?</b></summary>
|
||||
|
||||
1. Certifique-se de que o proxy está rodando (indicador verde na barra de status).
|
||||
2. Verifique se a porta corresponde à configuração da sua IDE (padrão: `10100`).
|
||||
2. Verifique se a porta corresponde à configuração da sua IDE (padrão: `8045`).
|
||||
3. Copie a API key da página Proxy e cole nas configurações da sua IDE.
|
||||
4. Verifique se pelo menos uma conta está ativa na página de Contas.
|
||||
</details>
|
||||
|
||||
+2
-2
@@ -201,7 +201,7 @@ _(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,
|
||||
在代理运行并至少添加一个账户后,配置您首选的 IDE(Cursor、Windsurf、OpenCode 等):
|
||||
|
||||
```plaintext
|
||||
API Base URL: http://localhost:10100/v1
|
||||
API Base URL: http://localhost:8045/v1
|
||||
API Key: (从应用的 Proxy 页面复制)
|
||||
Model: gemini-3-flash (或您选择的任何映射模型)
|
||||
```
|
||||
@@ -298,7 +298,7 @@ npm test
|
||||
<summary><b>问:IDE 无法连接到代理?</b></summary>
|
||||
|
||||
1. 确保代理正在运行(查看状态栏的绿色指示器)。
|
||||
2. 确认端口与您 IDE 中的配置一致(默认为 `10100`)。
|
||||
2. 确认端口与您 IDE 中的配置一致(默认为 `8045`)。
|
||||
3. 直接从 Proxy 页面复制 API 密钥并粘贴到 IDE 设置中。
|
||||
4. 确保在 Accounts 页面中至少有一个活跃的账户。
|
||||
</details>
|
||||
|
||||
+1
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.15.4",
|
||||
"version": "0.15.5",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
@@ -99,7 +99,6 @@
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@nestjs/common": "^11.1.18",
|
||||
"@nestjs/core": "^11.1.18",
|
||||
"@nestjs/microservices": "^11.1.18",
|
||||
@@ -116,19 +115,14 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.7.0",
|
||||
"@sentry/integrations": "^7.114.0",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-router": "^1.139.3",
|
||||
"@tanstack/react-router-devtools": "^1.135.0",
|
||||
|
||||
Generated
+3
-172
@@ -19,9 +19,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@icons-pack/react-simple-icons':
|
||||
specifier: ^13.8.0
|
||||
version: 13.13.0([email protected])
|
||||
'@nestjs/common':
|
||||
specifier: ^11.1.18
|
||||
version: 11.1.19([email protected])([email protected])([email protected])([email protected])
|
||||
@@ -70,15 +67,9 @@ importers:
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
version: 2.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2([email protected])
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-navigation-menu':
|
||||
specifier: ^1.2.14
|
||||
version: 1.2.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
@@ -94,21 +85,12 @@ importers:
|
||||
'@radix-ui/react-toast':
|
||||
specifier: ^1.2.15
|
||||
version: 1.2.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.9
|
||||
version: 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@sentry/electron':
|
||||
specifier: ^7.7.0
|
||||
version: 7.12.0
|
||||
'@sentry/integrations':
|
||||
specifier: ^7.114.0
|
||||
version: 7.114.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.7
|
||||
version: 5.100.5([email protected])
|
||||
@@ -1095,12 +1077,6 @@ packages:
|
||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@icons-pack/[email protected]':
|
||||
resolution: {integrity: sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==}
|
||||
engines: {node: '>=24', pnpm: '>=10'}
|
||||
peerDependencies:
|
||||
react: ^16.13 || ^17 || ^18 || ^19
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2276,11 +2252,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==}
|
||||
peerDependencies:
|
||||
react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
@@ -2316,19 +2287,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
@@ -2477,32 +2435,6 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/[email protected]':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
@@ -3020,10 +2952,6 @@ packages:
|
||||
resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-YnanVlmulkjgZiVZ9BfY9k6I082n+C+LbZo52MTvx3FY6RE5iyiPMpaOh67oXEZRWcYQEGm+bKruRxLVP6RlbA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-i8WkwxvtioaOKbE957GhoWaAuyiaUdjBNtJeus2VIg5o3RZaM871Q57WcQ3t2TF+U0z+IIlWgwPgGsLcsrPrDw==}
|
||||
peerDependencies:
|
||||
@@ -3032,10 +2960,6 @@ packages:
|
||||
'@sentry/node-native':
|
||||
optional: true
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-BJIBWXGKeIH0ifd7goxOS29fBA8BkEgVVCahs6xIOXBjX1IRS6PmX0zYx/GP23nQTfhJiubv2XPzoYOlZZmDxg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-7WO0KuCDPSq3G54TVUSI1CKFJwB67LasG+n/gDMBqbrarzs/Yh/s34OOMU5gfVQpncxQAmQsy4nEboQms8iNqA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3073,14 +2997,6 @@ packages:
|
||||
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0
|
||||
'@opentelemetry/semantic-conventions': ^1.39.0
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-tsqkkyL3eJtptmPtT0m9W/bPLkU7ILY7nvwpi1hahA5jrM7ppoU0IMaQWAgTD+U3rzFH40IdXNBFb8Gnqcva4w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-319N90McVpupQ6vws4+tfCy/03AdtsU0MurIE4+W5cubHME08HtiEWlfacvAxX+yuKFhvdsO4K4BB/dj54ideg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
resolution: {integrity: sha512-Tlyg2cyFYp/icX58GWvfpvZr9NLdLs2/xyFVyS8pQ0faZWmoXic3FMzoXYHV1gsdMbL1Yy5WQvGJy8j1rS8LGA==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -3751,6 +3667,7 @@ packages:
|
||||
'@xmldom/[email protected]':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xtuc/[email protected]':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
@@ -5090,6 +5007,7 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -6180,9 +6098,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
@@ -6286,9 +6201,6 @@ packages:
|
||||
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
||||
engines: {node: '>=6.11.5'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -8181,6 +8093,7 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
|
||||
engines: {node: ^18 || >=20}
|
||||
deprecated: unmaintained
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0
|
||||
@@ -9771,10 +9684,6 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/[email protected]': {}
|
||||
|
||||
'@icons-pack/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
'@img/[email protected]': {}
|
||||
|
||||
'@img/[email protected]':
|
||||
@@ -11172,10 +11081,6 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected]([email protected])':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected])([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
||||
@@ -11218,28 +11123,6 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5([email protected])
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.8([email protected]([email protected]))([email protected])
|
||||
@@ -11407,32 +11290,6 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-toggle': 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5([email protected])
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5([email protected])
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/[email protected])
|
||||
|
||||
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
@@ -11892,11 +11749,6 @@ snapshots:
|
||||
|
||||
'@sentry/[email protected]': {}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/types': 7.114.0
|
||||
'@sentry/utils': 7.114.0
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/browser': 10.49.0
|
||||
@@ -11906,13 +11758,6 @@ snapshots:
|
||||
- '@opentelemetry/exporter-trace-otlp-http'
|
||||
- supports-color
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/core': 7.114.0
|
||||
'@sentry/types': 7.114.0
|
||||
'@sentry/utils': 7.114.0
|
||||
localforage: 1.10.0
|
||||
|
||||
'@sentry/[email protected](@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected])':
|
||||
dependencies:
|
||||
'@sentry/core': 10.49.0
|
||||
@@ -11971,12 +11816,6 @@ snapshots:
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
'@sentry/core': 10.49.0
|
||||
|
||||
'@sentry/[email protected]': {}
|
||||
|
||||
'@sentry/[email protected]':
|
||||
dependencies:
|
||||
'@sentry/types': 7.114.0
|
||||
|
||||
'@sentry/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@sentry/bundler-plugin-core': 4.9.1([email protected])
|
||||
@@ -15482,10 +15321,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
@@ -15576,10 +15411,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
lie: 3.1.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
p-locate: 2.0.0
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import type { DeviceProfile } from '@/types/account';
|
||||
|
||||
export function listAccounts() {
|
||||
return ipc.client.account.listAccounts();
|
||||
}
|
||||
|
||||
export function addAccountSnapshot() {
|
||||
return ipc.client.account.addAccountSnapshot();
|
||||
}
|
||||
|
||||
export function switchAccount(accountId: string) {
|
||||
return ipc.client.account.switchAccount({ accountId });
|
||||
}
|
||||
|
||||
export function deleteAccount(accountId: string) {
|
||||
return ipc.client.account.deleteAccount({ accountId });
|
||||
}
|
||||
|
||||
export function previewGenerateIdentityProfile() {
|
||||
return ipc.client.account.previewGenerateIdentityProfile();
|
||||
}
|
||||
|
||||
export function getIdentityProfiles(accountId: string) {
|
||||
return ipc.client.account.getIdentityProfiles({ accountId });
|
||||
}
|
||||
|
||||
export function bindIdentityProfile(accountId: string, mode: 'capture' | 'generate') {
|
||||
return ipc.client.account.bindIdentityProfile({ accountId, mode });
|
||||
}
|
||||
|
||||
export function bindIdentityProfileWithPayload(accountId: string, profile: DeviceProfile) {
|
||||
return ipc.client.account.bindIdentityProfileWithPayload({ accountId, profile });
|
||||
}
|
||||
|
||||
export function applyBoundIdentityProfile(accountId: string) {
|
||||
return ipc.client.account.applyBoundIdentityProfile({ accountId });
|
||||
}
|
||||
|
||||
export function restoreIdentityProfileRevision(accountId: string, versionId: string) {
|
||||
return ipc.client.account.restoreIdentityProfileRevision({ accountId, versionId });
|
||||
}
|
||||
|
||||
export function deleteIdentityProfileRevision(accountId: string, versionId: string) {
|
||||
return ipc.client.account.deleteIdentityProfileRevision({ accountId, versionId });
|
||||
}
|
||||
|
||||
export function restoreBaselineProfile(accountId: string) {
|
||||
return ipc.client.account.restoreBaselineProfile({ accountId });
|
||||
}
|
||||
|
||||
export function openIdentityStorageFolder() {
|
||||
return ipc.client.account.openIdentityStorageFolder();
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { AccountBackupData, Account } from '@/types/account';
|
||||
|
||||
export function backupAccount(account: Account) {
|
||||
return ipc.client.database.backupAccount(account);
|
||||
}
|
||||
|
||||
export function restoreAccount(backup: AccountBackupData) {
|
||||
return ipc.client.database.restoreAccount(backup);
|
||||
}
|
||||
|
||||
export function getCurrentAccountInfo() {
|
||||
return ipc.client.database.getCurrentAccountInfo();
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ipc } from '../ipc/manager';
|
||||
|
||||
export async function minimizeWindow() {
|
||||
await ipc.client.window.minimizeWindow();
|
||||
}
|
||||
export async function maximizeWindow() {
|
||||
await ipc.client.window.maximizeWindow();
|
||||
}
|
||||
export async function closeWindow() {
|
||||
await ipc.client.window.closeWindow();
|
||||
}
|
||||
+866
-866
File diff suppressed because it is too large.
Load diff
@@ -112,6 +112,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('auto')}
|
||||
aria-label={t('cloud.layout.auto')}
|
||||
aria-pressed={gridLayout === 'auto'}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -125,6 +127,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('2-col')}
|
||||
aria-label={t('cloud.layout.twoCol')}
|
||||
aria-pressed={gridLayout === '2-col'}
|
||||
>
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -138,6 +142,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('3-col')}
|
||||
aria-label={t('cloud.layout.threeCol')}
|
||||
aria-pressed={gridLayout === '3-col'}
|
||||
>
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -151,6 +157,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('list')}
|
||||
aria-label={t('cloud.layout.list')}
|
||||
aria-pressed={gridLayout === 'list'}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -164,6 +172,8 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
size="icon"
|
||||
className="h-7 w-7 cursor-pointer rounded-md"
|
||||
onClick={() => onLayoutChange('compact')}
|
||||
aria-label={t('cloud.layout.compact')}
|
||||
aria-pressed={gridLayout === 'compact'}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -206,7 +216,12 @@ export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||
{/* More Options Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="cursor-pointer bg-white/[0.02]">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="cursor-pointer bg-white/[0.02]"
|
||||
aria-label={t('common.openMenu')}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -29,6 +29,7 @@ export function BatchActionBar({
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-full"
|
||||
onClick={onClearSelection}
|
||||
aria-label={t('cloud.batch.clear')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { getPlatform } from '@/actions/app';
|
||||
import { closeWindow, maximizeWindow, minimizeWindow } from '@/actions/window';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
interface DragWindowRegionProps {
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
export default function DragWindowRegion({ title }: DragWindowRegionProps) {
|
||||
const [platform, setPlatform] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getPlatform()
|
||||
.then((value: any) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setPlatform(value as string);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
logger.error('Failed to detect platform', error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isMacOS = platform === 'darwin';
|
||||
|
||||
return (
|
||||
<div className="flex w-screen items-stretch justify-between">
|
||||
<div className="draglayer w-full">
|
||||
{title && !isMacOS && (
|
||||
<div className="text-muted-foreground flex flex-1 p-2 text-xs whitespace-nowrap select-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{isMacOS && (
|
||||
<div className="flex flex-1 p-2">
|
||||
{/* Maintain the same height but do not display content */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isMacOS && <WindowButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowButtons() {
|
||||
return (
|
||||
<div className="flex">
|
||||
<button
|
||||
title="Minimize"
|
||||
type="button"
|
||||
className="p-2 hover:bg-slate-300"
|
||||
onClick={minimizeWindow}
|
||||
>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect fill="currentColor" width="10" height="1" x="1" y="6"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
title="Maximize"
|
||||
type="button"
|
||||
className="p-2 hover:bg-slate-300"
|
||||
onClick={maximizeWindow}
|
||||
>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect width="9" height="9" x="1.5" y="1.5" fill="none" stroke="currentColor"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" title="Close" className="p-2 hover:bg-red-300" onClick={closeWindow}>
|
||||
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
|
||||
<polygon
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
points="11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1"
|
||||
></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import {
|
||||
NavigationMenu as NavigationMenuBase,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NavigationMenu() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NavigationMenuBase className="text-muted-foreground px-2">
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link to="/">{t('titleHomePage')}</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenuBase>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn('relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), 'group', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{' '}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
|
||||
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -1,44 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-2 min-w-9',
|
||||
sm: 'h-8 px-1.5 min-w-8',
|
||||
lg: 'h-10 px-2.5 min-w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -258,6 +258,7 @@ export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
|
||||
className="text-muted-foreground hover:text-foreground h-6 w-6"
|
||||
onClick={() => handleReset(model)}
|
||||
title="Reset to default"
|
||||
aria-label={`Reset ${model} pricing to default`}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { CloudAccountSchema } from '../../types/cloudAccount';
|
||||
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { redactCloudAccountTokens } from '../../utils/cloud-account-redaction';
|
||||
import { getSwitchMetricsSnapshot } from '../switchMetrics';
|
||||
import { getSwitchGuardSnapshot } from '../switchGuard';
|
||||
import { getDeviceHardeningSnapshot } from '../device/handler';
|
||||
@@ -107,11 +108,11 @@ export const cloudRouter = os.router({
|
||||
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||
return redactCloudAccountTokens(await addGoogleAccount(input.authCode, input.oauthClientKey));
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
return listCloudAccounts();
|
||||
return (await listCloudAccounts()).map(redactCloudAccountTokens);
|
||||
}),
|
||||
|
||||
deleteCloudAccount: os
|
||||
@@ -125,7 +126,7 @@ export const cloudRouter = os.router({
|
||||
.input(z.object({ accountId: z.string() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return refreshAccountQuota(input.accountId);
|
||||
return redactCloudAccountTokens(await refreshAccountQuota(input.accountId));
|
||||
}),
|
||||
|
||||
switchCloudAccount: os
|
||||
@@ -206,7 +207,7 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
const result = await CloudAccountRepo.syncFromIDE();
|
||||
|
||||
return result;
|
||||
return result ? redactCloudAccountTokens(result) : result;
|
||||
} catch (error: unknown) {
|
||||
logger.error(
|
||||
'[ORPC] syncLocalAccount error:',
|
||||
|
||||
@@ -453,16 +453,21 @@ export async function generateIdeConfig(
|
||||
const apiKey = config?.api_key || 'YOUR_API_KEY';
|
||||
|
||||
let localIp = '127.0.0.1';
|
||||
const interfaces = networkInterfaces();
|
||||
for (const iface of Object.values(interfaces)) {
|
||||
if (iface) {
|
||||
for (const entry of iface) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) {
|
||||
localIp = entry.address;
|
||||
break;
|
||||
try {
|
||||
const interfaces = networkInterfaces();
|
||||
for (const iface of Object.values(interfaces)) {
|
||||
if (iface) {
|
||||
for (const entry of iface) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) {
|
||||
localIp = entry.address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Local proxy configuration remains usable when LAN enumeration is restricted.
|
||||
logger.warn('Could not enumerate network interfaces; using loopback', error);
|
||||
}
|
||||
|
||||
// Fallback generator mimicking the proxyIdeConfig.service
|
||||
|
||||
@@ -42,6 +42,139 @@ interface RequestConfig {
|
||||
|
||||
// --- Main Logic ---
|
||||
|
||||
type ToolConfig = { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||
|
||||
/**
|
||||
* Maps the request tool_choice to a Gemini toolConfig.
|
||||
* Without declared tools only an explicit 'none' produces a config.
|
||||
*/
|
||||
function resolveToolConfig(
|
||||
toolChoice: ClaudeRequest['tool_choice'],
|
||||
hasTools: boolean,
|
||||
): ToolConfig | undefined {
|
||||
if (toolChoice === 'none') {
|
||||
return { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
if (!hasTools) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof toolChoice === 'object' &&
|
||||
toolChoice.type === 'function' &&
|
||||
toolChoice.function?.name
|
||||
) {
|
||||
return {
|
||||
functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name] },
|
||||
};
|
||||
}
|
||||
return { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
|
||||
function resolveThinkingEnabled(claudeReq: ClaudeRequest, finalModel: string): boolean {
|
||||
const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
if (thinkingType === 'enabled' || thinkingType === 'adaptive') {
|
||||
return true;
|
||||
}
|
||||
return !claudeReq.thinking && shouldEnableThinkingByDefault(finalModel, claudeReq.model);
|
||||
}
|
||||
|
||||
function buildSafetySettings(): SafetySetting[] {
|
||||
return [
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'OFF' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Image generation endpoints reject tools, system prompts and thinking/JSON output settings,
|
||||
* so they are stripped before the image config is attached.
|
||||
*/
|
||||
function applyImageGenerationOverrides(
|
||||
innerRequest: {
|
||||
tools?: unknown;
|
||||
systemInstruction?: unknown;
|
||||
generationConfig?: GenerationConfig;
|
||||
},
|
||||
imageConfig: ImageConfig,
|
||||
): void {
|
||||
delete innerRequest.tools;
|
||||
delete innerRequest.systemInstruction;
|
||||
|
||||
const genConfig = innerRequest.generationConfig || {};
|
||||
delete genConfig.thinkingConfig;
|
||||
delete genConfig.responseMimeType;
|
||||
delete genConfig.responseModalities;
|
||||
genConfig.imageConfig = imageConfig;
|
||||
innerRequest.generationConfig = genConfig;
|
||||
}
|
||||
|
||||
interface InnerRequest {
|
||||
contents: GeminiContent[];
|
||||
safetySettings: SafetySetting[];
|
||||
systemInstruction?: { parts: { text: string }[] };
|
||||
generationConfig?: GenerationConfig;
|
||||
tools?: GeminiToolDeclaration[];
|
||||
toolConfig?: ToolConfig;
|
||||
}
|
||||
|
||||
/** Copies only the sections that are present, so empty ones never reach the upstream payload. */
|
||||
function attachOptionalSections(
|
||||
innerRequest: InnerRequest,
|
||||
sections: {
|
||||
systemInstruction: InnerRequest['systemInstruction'] | null;
|
||||
generationConfig: GenerationConfig;
|
||||
tools: InnerRequest['tools'] | null;
|
||||
toolConfig: ToolConfig | undefined;
|
||||
},
|
||||
): void {
|
||||
if (sections.systemInstruction) {
|
||||
innerRequest.systemInstruction = sections.systemInstruction;
|
||||
}
|
||||
if (Object.keys(sections.generationConfig).length > 0) {
|
||||
innerRequest.generationConfig = sections.generationConfig;
|
||||
}
|
||||
if (sections.tools) {
|
||||
innerRequest.tools = sections.tools;
|
||||
}
|
||||
if (sections.toolConfig) {
|
||||
innerRequest.toolConfig = sections.toolConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequestEnvelope(
|
||||
innerRequest: object,
|
||||
config: RequestConfig,
|
||||
claudeReq: ClaudeRequest,
|
||||
projectId: string | undefined,
|
||||
userAgent: string | undefined,
|
||||
): GeminiInternalRequest {
|
||||
const requestId = `agent-${uuidv4()}`;
|
||||
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const body: GeminiInternalRequest = {
|
||||
requestId: requestId,
|
||||
request: innerRequest as GeminiInternalRequest['request'],
|
||||
model: config.finalModel,
|
||||
userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion),
|
||||
requestType: config.requestType,
|
||||
};
|
||||
|
||||
if (normalizedProjectId) {
|
||||
body.project = normalizedProjectId;
|
||||
}
|
||||
|
||||
if (claudeReq.metadata?.user_id) {
|
||||
body.sessionId = claudeReq.metadata.user_id;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms Claude request into Gemini internal request format
|
||||
* @param claudeReq Claude API request
|
||||
@@ -76,15 +209,10 @@ export function transformClaudeRequestIn(
|
||||
const allowDummyThought = config.finalModel.startsWith('gemini-');
|
||||
|
||||
// 4. Generation Config & Thinking
|
||||
const thinkingType = (claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
const autoThinkingEnabled =
|
||||
!claudeReq.thinking && shouldEnableThinkingByDefault(config.finalModel, claudeReq.model);
|
||||
let isThinkingEnabled =
|
||||
thinkingType === 'enabled' || thinkingType === 'adaptive' || autoThinkingEnabled;
|
||||
|
||||
// Thinking is always kept enabled for thinking-capable models.
|
||||
// All functionCall/functionResponse/thinking parts get unconditional
|
||||
// thoughtSignature injection in buildContents(), so no guard is needed here.
|
||||
const isThinkingEnabled = resolveThinkingEnabled(claudeReq, config.finalModel);
|
||||
|
||||
const generationConfig = buildGenerationConfig(
|
||||
claudeReq,
|
||||
@@ -110,61 +238,16 @@ export function transformClaudeRequestIn(
|
||||
const tools = buildTools(claudeReq.tools, hasWebSearchTool, config.finalModel);
|
||||
|
||||
// 5. Safety Settings
|
||||
const safetySettings = [
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' },
|
||||
{ category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'OFF' },
|
||||
];
|
||||
|
||||
// Build inner request
|
||||
const innerRequest: {
|
||||
contents: GeminiContent[];
|
||||
safetySettings: SafetySetting[];
|
||||
systemInstruction?: { parts: { text: string }[] };
|
||||
generationConfig?: GenerationConfig;
|
||||
tools?: GeminiToolDeclaration[];
|
||||
toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||
} = {
|
||||
contents,
|
||||
safetySettings,
|
||||
};
|
||||
const safetySettings = buildSafetySettings();
|
||||
|
||||
const innerRequest: InnerRequest = { contents, safetySettings };
|
||||
deepCleanUndefined(innerRequest);
|
||||
|
||||
if (systemInstruction) {
|
||||
innerRequest.systemInstruction = systemInstruction;
|
||||
}
|
||||
|
||||
if (generationConfig && Object.keys(generationConfig).length > 0) {
|
||||
innerRequest.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (tools) {
|
||||
innerRequest.tools = tools;
|
||||
const toolChoice = claudeReq.tool_choice;
|
||||
if (toolChoice === 'none') {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (toolChoice === 'auto' || toolChoice === undefined) {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (
|
||||
typeof toolChoice === 'object' &&
|
||||
toolChoice.type === 'function' &&
|
||||
toolChoice.function?.name
|
||||
) {
|
||||
innerRequest.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [toolChoice.function.name],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
}
|
||||
} else if (claudeReq.tool_choice === 'none') {
|
||||
innerRequest.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
}
|
||||
attachOptionalSections(innerRequest, {
|
||||
systemInstruction,
|
||||
generationConfig,
|
||||
tools,
|
||||
toolConfig: resolveToolConfig(claudeReq.tool_choice, !!tools),
|
||||
});
|
||||
|
||||
// Inject googleSearch tool if needed (and not already done by buildTools)
|
||||
if (config.injectGoogleSearch && !hasWebSearchTool) {
|
||||
@@ -173,42 +256,10 @@ export function transformClaudeRequestIn(
|
||||
|
||||
// Inject imageConfig if present (for image generation models)
|
||||
if (config.imageConfig) {
|
||||
// 1. Remove tools (image generation does not support tools)
|
||||
delete innerRequest.tools;
|
||||
// 2. Remove systemInstruction (image generation does not support system prompts)
|
||||
delete innerRequest.systemInstruction;
|
||||
|
||||
// 3. Clean generationConfig
|
||||
const genConfig = innerRequest.generationConfig || {};
|
||||
delete genConfig.thinkingConfig;
|
||||
delete genConfig.responseMimeType;
|
||||
delete genConfig.responseModalities;
|
||||
genConfig.imageConfig = config.imageConfig;
|
||||
innerRequest.generationConfig = genConfig;
|
||||
applyImageGenerationOverrides(innerRequest, config.imageConfig);
|
||||
}
|
||||
|
||||
const requestId = `agent-${uuidv4()}`;
|
||||
|
||||
const normalizedProjectId = projectId?.trim();
|
||||
|
||||
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
|
||||
const body: GeminiInternalRequest = {
|
||||
requestId: requestId,
|
||||
request: innerRequest as GeminiInternalRequest['request'],
|
||||
model: config.finalModel,
|
||||
userAgent: userAgent?.trim() || buildUserAgent(discoveryVersion),
|
||||
requestType: config.requestType,
|
||||
};
|
||||
|
||||
if (normalizedProjectId) {
|
||||
body.project = normalizedProjectId;
|
||||
}
|
||||
|
||||
if (claudeReq.metadata?.user_id) {
|
||||
body.sessionId = claudeReq.metadata.user_id;
|
||||
}
|
||||
|
||||
return body;
|
||||
return buildRequestEnvelope(innerRequest, config, claudeReq, projectId, userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,6 +515,142 @@ function hasValidSignatureForFunctionCalls(
|
||||
return false;
|
||||
}
|
||||
|
||||
type ContentPart = GeminiContent['parts'][number];
|
||||
|
||||
interface ContentBuildState {
|
||||
/** Most recent thought signature seen while walking the messages */
|
||||
lastThoughtSignature: string | null;
|
||||
toolIdToName: Map<string, string>;
|
||||
}
|
||||
|
||||
type ContentBlock = Exclude<Message['content'], string | undefined>[number];
|
||||
|
||||
function buildTextPart(block: any, msg: Message): ContentPart | null {
|
||||
if (!block.text || block.text === '(no content)' || isEmpty(block.text.trim())) {
|
||||
return null;
|
||||
}
|
||||
let text = block.text.trim();
|
||||
if (msg.role === 'function' && msg.name) {
|
||||
text = `Function result (${msg.name}):\n${text}`;
|
||||
} else if (msg.name) {
|
||||
text = `[${msg.name}]: ${text}`;
|
||||
}
|
||||
return { text };
|
||||
}
|
||||
|
||||
function buildThinkingPart(block: any, state: ContentBuildState): ContentPart {
|
||||
const part: any = { text: block.thinking, thought: true };
|
||||
cleanJsonSchema(part);
|
||||
if (block.signature) {
|
||||
state.lastThoughtSignature = block.signature;
|
||||
part.thoughtSignature = block.signature;
|
||||
} else {
|
||||
// Inject fallback signature for thinking blocks without one
|
||||
part.thoughtSignature = SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
function buildToolUsePart(block: any, state: ContentBuildState): ContentPart {
|
||||
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
|
||||
cleanJsonSchema(part);
|
||||
state.toolIdToName.set(block.id, block.name);
|
||||
// Always inject thoughtSignature on functionCall parts.
|
||||
// The v1internal API rejects functionCalls without signatures when thinking is enabled.
|
||||
const finalSig = block.signature || state.lastThoughtSignature || SignatureStore.get();
|
||||
part.thoughtSignature = finalSig || 'skip_thought_signature_validator';
|
||||
return part;
|
||||
}
|
||||
|
||||
function mergeToolResultContent(content: any): string {
|
||||
if (isString(content)) {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((b: any) => b.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildToolResultPart(block: any, state: ContentBuildState): ContentPart {
|
||||
const funcName = state.toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||
let mergedContent = mergeToolResultContent(block.content);
|
||||
if (isEmpty(mergedContent.trim())) {
|
||||
mergedContent = block.is_error
|
||||
? 'Tool execution failed with no output.'
|
||||
: 'Command executed successfully.';
|
||||
}
|
||||
const part: any = {
|
||||
functionResponse: {
|
||||
name: funcName,
|
||||
response: block.is_error ? { error: mergedContent } : { result: mergedContent },
|
||||
id: block.tool_use_id,
|
||||
},
|
||||
};
|
||||
// Always inject thoughtSignature on functionResponse parts for v1internal compatibility
|
||||
part.thoughtSignature =
|
||||
state.lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
return part;
|
||||
}
|
||||
|
||||
function buildPartFromBlock(
|
||||
block: ContentBlock,
|
||||
msg: Message,
|
||||
state: ContentBuildState,
|
||||
): ContentPart | null {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return buildTextPart(block, msg);
|
||||
case 'thinking':
|
||||
return buildThinkingPart(block, state);
|
||||
case 'image':
|
||||
return block.source.type === 'base64'
|
||||
? { inlineData: { mimeType: block.source.media_type, data: block.source.data } }
|
||||
: null;
|
||||
case 'tool_use':
|
||||
return buildToolUsePart(block, state);
|
||||
case 'tool_result':
|
||||
return buildToolResultPart(block, state);
|
||||
case 'redacted_thinking':
|
||||
return { text: `[Redacted Thinking: ${block.data}]`, thought: true };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getContentBlocks(msg: Message): ContentBlock[] {
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content;
|
||||
}
|
||||
return msg.content ? [{ type: 'text' as const, text: msg.content }] : [];
|
||||
}
|
||||
|
||||
function toGeminiRole(role: string): string {
|
||||
if (role === 'assistant') return 'model';
|
||||
return role === 'function' ? 'user' : role;
|
||||
}
|
||||
|
||||
function buildMessageParts(msg: Message, state: ContentBuildState): ContentPart[] {
|
||||
const parts: ContentPart[] = [];
|
||||
for (const block of getContentBlocks(msg)) {
|
||||
const part = buildPartFromBlock(block, msg, state);
|
||||
if (part) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Prepends a placeholder thought when the final model turn carries none. */
|
||||
function ensureThoughtPart(parts: ContentPart[]): void {
|
||||
if (!parts.some((p) => p.thought === true)) {
|
||||
parts.unshift({ text: 'Thinking...', thought: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds message contents
|
||||
* Converts Claude message list to Gemini content format
|
||||
@@ -476,96 +663,16 @@ function buildContents(
|
||||
mappedModel: string,
|
||||
): GeminiContent[] {
|
||||
const contents: GeminiContent[] = [];
|
||||
let lastThoughtSignature: string | null = null;
|
||||
const state: ContentBuildState = { lastThoughtSignature: null, toolIdToName };
|
||||
|
||||
const lastIndex = messages.length - 1;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
let role = msg.role === 'assistant' ? 'model' : msg.role;
|
||||
if (role === 'function') {
|
||||
role = 'user';
|
||||
}
|
||||
const parts: {
|
||||
text?: string;
|
||||
inlineData?: {
|
||||
mimeType: string;
|
||||
data: string;
|
||||
};
|
||||
thought?: boolean;
|
||||
}[] = [];
|
||||
const contentBlocks = Array.isArray(msg.content)
|
||||
? msg.content
|
||||
: msg.content
|
||||
? [{ type: 'text' as const, text: msg.content }]
|
||||
: [];
|
||||
|
||||
for (const block of contentBlocks) {
|
||||
if (block.type === 'text') {
|
||||
if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim())) {
|
||||
let text = block.text.trim();
|
||||
if (msg.role === 'function' && msg.name) {
|
||||
text = `Function result (${msg.name}):\n${text}`;
|
||||
} else if (msg.name) {
|
||||
text = `[${msg.name}]: ${text}`;
|
||||
}
|
||||
parts.push({ text });
|
||||
}
|
||||
} else if (block.type === 'thinking') {
|
||||
const part: any = { text: block.thinking, thought: true };
|
||||
cleanJsonSchema(part);
|
||||
if (block.signature) {
|
||||
lastThoughtSignature = block.signature;
|
||||
part.thoughtSignature = block.signature;
|
||||
} else {
|
||||
// Inject fallback signature for thinking blocks without one
|
||||
const fallbackSig = SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
part.thoughtSignature = fallbackSig;
|
||||
}
|
||||
parts.push(part);
|
||||
} else if (block.type === 'image') {
|
||||
if (block.source.type === 'base64')
|
||||
parts.push({
|
||||
inlineData: { mimeType: block.source.media_type, data: block.source.data },
|
||||
});
|
||||
} else if (block.type === 'tool_use') {
|
||||
const part: any = { functionCall: { name: block.name, args: block.input, id: block.id } };
|
||||
cleanJsonSchema(part);
|
||||
toolIdToName.set(block.id, block.name);
|
||||
// Always inject thoughtSignature on functionCall parts.
|
||||
// The v1internal API rejects functionCalls without signatures when thinking is enabled.
|
||||
const finalSig = block.signature || lastThoughtSignature || SignatureStore.get();
|
||||
part.thoughtSignature = finalSig || 'skip_thought_signature_validator';
|
||||
parts.push(part);
|
||||
} else if (block.type === 'tool_result') {
|
||||
const funcName = toolIdToName.get(block.tool_use_id) || block.tool_use_id;
|
||||
let mergedContent = '';
|
||||
if (isString(block.content)) mergedContent = block.content;
|
||||
else if (Array.isArray(block.content))
|
||||
mergedContent = block.content
|
||||
.filter((b: any) => b.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join('\n');
|
||||
if (isEmpty(mergedContent.trim()))
|
||||
mergedContent = block.is_error
|
||||
? 'Tool execution failed with no output.'
|
||||
: 'Command executed successfully.';
|
||||
const part: any = {
|
||||
functionResponse: {
|
||||
name: funcName,
|
||||
response: block.is_error ? { error: mergedContent } : { result: mergedContent },
|
||||
id: block.tool_use_id,
|
||||
},
|
||||
};
|
||||
// Always inject thoughtSignature on functionResponse parts for v1internal compatibility
|
||||
part.thoughtSignature =
|
||||
lastThoughtSignature || SignatureStore.get() || 'skip_thought_signature_validator';
|
||||
parts.push(part);
|
||||
} else if (block.type === 'redacted_thinking') {
|
||||
parts.push({ text: `[Redacted Thinking: ${block.data}]`, thought: true });
|
||||
}
|
||||
}
|
||||
if (allowDummyThought && role === 'model' && isThinkingEnabled && i === messages.length - 1) {
|
||||
const hasThought = parts.some((p) => p.thought === true);
|
||||
if (!hasThought) parts.unshift({ text: 'Thinking...', thought: true });
|
||||
const role = toGeminiRole(msg.role);
|
||||
const parts = buildMessageParts(msg, state);
|
||||
if (allowDummyThought && isThinkingEnabled && role === 'model' && i === lastIndex) {
|
||||
ensureThoughtPart(parts);
|
||||
}
|
||||
if (parts.length > 0) contents.push({ role, parts });
|
||||
}
|
||||
@@ -626,6 +733,91 @@ function buildTools(
|
||||
return null;
|
||||
}
|
||||
|
||||
const DEFAULT_STOP_SEQUENCES = [
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'[DONE]',
|
||||
'\n\nHuman:',
|
||||
];
|
||||
|
||||
function buildThinkingConfig(
|
||||
claudeReq: ClaudeRequest,
|
||||
hasWebSearch: boolean,
|
||||
mappedModel: string,
|
||||
): GenerationConfig['thinkingConfig'] {
|
||||
const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
function applyOpenAISampling(
|
||||
config: GenerationConfig,
|
||||
claudeReq: ClaudeRequest,
|
||||
mappedModel: string,
|
||||
maxTokens: number | undefined,
|
||||
): void {
|
||||
config.temperature = claudeReq.temperature ?? 1.0;
|
||||
config.topP = claudeReq.top_p ?? 0.95;
|
||||
config.maxOutputTokens = maxTokens ?? getMaxOutputTokens(mappedModel);
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = claudeReq.stop_sequences;
|
||||
}
|
||||
}
|
||||
|
||||
function applyNativeSampling(
|
||||
config: GenerationConfig,
|
||||
claudeReq: ClaudeRequest,
|
||||
maxTokens: number | undefined,
|
||||
): void {
|
||||
if (claudeReq.temperature !== undefined) {
|
||||
config.temperature = claudeReq.temperature;
|
||||
}
|
||||
if (claudeReq.top_p !== undefined) {
|
||||
config.topP = claudeReq.top_p;
|
||||
}
|
||||
if (claudeReq.top_k !== undefined) {
|
||||
config.topK = claudeReq.top_k;
|
||||
}
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
}
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...DEFAULT_STOP_SEQUENCES])];
|
||||
} else {
|
||||
config.stopSequences = [...DEFAULT_STOP_SEQUENCES];
|
||||
}
|
||||
}
|
||||
|
||||
function applyResponseFormat(config: GenerationConfig, claudeReq: ClaudeRequest): void {
|
||||
const format = claudeReq.response_format;
|
||||
if (!format) {
|
||||
return;
|
||||
}
|
||||
if (format.type === 'json_object') {
|
||||
config.responseMimeType = 'application/json';
|
||||
} else if (format.type === 'json_schema' && format.json_schema) {
|
||||
config.responseMimeType = 'application/json';
|
||||
config.responseSchema = cleanJsonSchema(format.json_schema);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* build generation config
|
||||
* convert claude request parameters to gemini generation config
|
||||
@@ -637,89 +829,22 @@ function buildGenerationConfig(
|
||||
isThinkingEnabled: boolean,
|
||||
): GenerationConfig {
|
||||
const source = String(claudeReq.metadata?.source || '').toLowerCase();
|
||||
const isOpenAIPath = source === 'openai';
|
||||
const config: GenerationConfig = {};
|
||||
const thinkingType = String(claudeReq.thinking?.type ?? '').toLowerCase();
|
||||
|
||||
const buildThinkingConfig = (): GenerationConfig['thinkingConfig'] => {
|
||||
const thinkingConfig: GenerationConfig['thinkingConfig'] = { includeThoughts: true };
|
||||
if (thinkingType === 'adaptive') {
|
||||
if (isClaudeModel(mappedModel)) {
|
||||
thinkingConfig.thinkingLevel = resolveAdaptiveThinkingLevel(claudeReq);
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = 24576;
|
||||
}
|
||||
} else if (claudeReq.thinking?.budget_tokens) {
|
||||
let budget = claudeReq.thinking.budget_tokens;
|
||||
const isFlash = hasWebSearch || isGeminiFlashModel(mappedModel);
|
||||
if (isFlash) {
|
||||
budget = Math.min(budget, 24576);
|
||||
}
|
||||
thinkingConfig.thinkingBudget = budget;
|
||||
} else {
|
||||
thinkingConfig.thinkingBudget = getThinkingBudget(mappedModel);
|
||||
}
|
||||
return thinkingConfig;
|
||||
};
|
||||
|
||||
const maxTokens = claudeReq.max_completion_tokens ?? claudeReq.max_tokens;
|
||||
|
||||
if (isOpenAIPath) {
|
||||
config.temperature = claudeReq.temperature ?? 1.0;
|
||||
config.topP = claudeReq.top_p ?? 0.95;
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
} else {
|
||||
config.maxOutputTokens = getMaxOutputTokens(mappedModel);
|
||||
}
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = claudeReq.stop_sequences;
|
||||
}
|
||||
if (source === 'openai') {
|
||||
applyOpenAISampling(config, claudeReq, mappedModel, maxTokens);
|
||||
if (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
config.thinkingConfig = buildThinkingConfig(claudeReq, hasWebSearch, mappedModel);
|
||||
}
|
||||
} else {
|
||||
if (isThinkingEnabled) {
|
||||
config.thinkingConfig = buildThinkingConfig();
|
||||
}
|
||||
if (claudeReq.temperature !== undefined) {
|
||||
config.temperature = claudeReq.temperature;
|
||||
}
|
||||
if (claudeReq.top_p !== undefined) {
|
||||
config.topP = claudeReq.top_p;
|
||||
}
|
||||
if (claudeReq.top_k !== undefined) {
|
||||
config.topK = claudeReq.top_k;
|
||||
}
|
||||
if (maxTokens !== undefined) {
|
||||
config.maxOutputTokens = maxTokens;
|
||||
}
|
||||
const defaultStopSequences = [
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'[DONE]',
|
||||
'\n\nHuman:',
|
||||
];
|
||||
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
|
||||
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
|
||||
} else {
|
||||
config.stopSequences = defaultStopSequences;
|
||||
}
|
||||
}
|
||||
|
||||
if (claudeReq.response_format) {
|
||||
if (claudeReq.response_format.type === 'json_object') {
|
||||
config.responseMimeType = 'application/json';
|
||||
} else if (
|
||||
claudeReq.response_format.type === 'json_schema' &&
|
||||
claudeReq.response_format.json_schema
|
||||
) {
|
||||
config.responseMimeType = 'application/json';
|
||||
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
|
||||
config.thinkingConfig = buildThinkingConfig(claudeReq, hasWebSearch, mappedModel);
|
||||
}
|
||||
applyNativeSampling(config, claudeReq, maxTokens);
|
||||
}
|
||||
|
||||
applyResponseFormat(config, claudeReq);
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,92 @@ export function normalizeGeminiModelAlias(modelId: string): string {
|
||||
return GEMINI_MODEL_ALIASES[normalizedModelId] ?? modelId;
|
||||
}
|
||||
|
||||
interface OpenAIRouteRule {
|
||||
matches: (lowerModel: string) => boolean;
|
||||
/** Mapping keys tried in order; the first one configured by the user wins */
|
||||
targets: { key: string; label: string }[];
|
||||
}
|
||||
|
||||
/** Evaluated in order; a rule whose mapping keys are all unset falls through to the next one. */
|
||||
const OPENAI_ROUTE_RULES: OpenAIRouteRule[] = [
|
||||
{
|
||||
// GPT-4 Series (includes GPT-4 classic, o1, o3, etc., excludes 4o/mini/turbo)
|
||||
matches: (lowerModel) =>
|
||||
(lowerModel.startsWith('gpt-4') &&
|
||||
!lowerModel.includes('o') &&
|
||||
!lowerModel.includes('mini') &&
|
||||
!lowerModel.includes('turbo')) ||
|
||||
lowerModel.startsWith('o1-') ||
|
||||
lowerModel.startsWith('o3-') ||
|
||||
lowerModel === 'gpt-4',
|
||||
targets: [{ key: 'gpt-4-series', label: 'GPT-4 series' }],
|
||||
},
|
||||
{
|
||||
// GPT-4o / 3.5 Series (Balanced & Lightweight, includes 4o, mini, turbo)
|
||||
matches: (lowerModel) =>
|
||||
lowerModel.includes('4o') ||
|
||||
lowerModel.startsWith('gpt-3.5') ||
|
||||
(lowerModel.includes('mini') && !lowerModel.includes('gemini')) ||
|
||||
lowerModel.includes('turbo'),
|
||||
targets: [{ key: 'gpt-4o-series', label: 'GPT-4o/3.5 series' }],
|
||||
},
|
||||
{
|
||||
// GPT-5 Series (gpt-5, gpt-5.1, gpt-5.2, etc.); falls back to the GPT-4 series mapping
|
||||
matches: (lowerModel) => lowerModel.startsWith('gpt-5'),
|
||||
targets: [
|
||||
{ key: 'gpt-5-series', label: 'GPT-5 series' },
|
||||
{ key: 'gpt-4-series', label: 'GPT-4 series (GPT-5 fallback)' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function resolveOpenAIFamilyRoute(
|
||||
originalModel: string,
|
||||
lowerModel: string,
|
||||
openaiMapping: Record<string, string>,
|
||||
): string | undefined {
|
||||
for (const rule of OPENAI_ROUTE_RULES) {
|
||||
if (!rule.matches(lowerModel)) {
|
||||
continue;
|
||||
}
|
||||
for (const target of rule.targets) {
|
||||
const mappedModel = openaiMapping[target.key];
|
||||
if (mappedModel) {
|
||||
logger.info(`[Router] Using ${target.label} mapping: ${originalModel} -> ${mappedModel}`);
|
||||
return mappedModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAnthropicFamilyRoute(
|
||||
originalModel: string,
|
||||
lowerModel: string,
|
||||
anthropicMapping: Record<string, string>,
|
||||
): string | undefined {
|
||||
if (!lowerModel.startsWith('claude-')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let familyKey = 'claude-default';
|
||||
if (lowerModel.includes('4-5') || lowerModel.includes('4.5')) {
|
||||
familyKey = 'claude-4.5-series';
|
||||
} else if (lowerModel.includes('3-5') || lowerModel.includes('3.5')) {
|
||||
familyKey = 'claude-3.5-series';
|
||||
}
|
||||
|
||||
if (anthropicMapping[familyKey]) {
|
||||
logger.warn(
|
||||
`[Router] Using Anthropic series mapping: ${originalModel} -> ${anthropicMapping[familyKey]}`,
|
||||
);
|
||||
return anthropicMapping[familyKey];
|
||||
}
|
||||
|
||||
// Fallback to legacy exact mapping
|
||||
return anthropicMapping[originalModel] || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core Model Routing Engine
|
||||
* Priority: Custom Mapping (Exact) > Group Mapping (Family) > System Mapping (Built-in Plugin)
|
||||
@@ -199,80 +285,15 @@ export function resolveModelRoute(
|
||||
|
||||
const lowerModel = originalModel.toLowerCase();
|
||||
|
||||
// 2. Check family group mapping (OpenAI Series)
|
||||
// GPT-4 Series (includes GPT-4 classic, o1, o3, etc., excludes 4o/mini/turbo)
|
||||
if (
|
||||
(lowerModel.startsWith('gpt-4') &&
|
||||
!lowerModel.includes('o') &&
|
||||
!lowerModel.includes('mini') &&
|
||||
!lowerModel.includes('turbo')) ||
|
||||
lowerModel.startsWith('o1-') ||
|
||||
lowerModel.startsWith('o3-') ||
|
||||
lowerModel === 'gpt-4'
|
||||
) {
|
||||
if (openaiMapping['gpt-4-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-4 series mapping: ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4-series'];
|
||||
}
|
||||
// 2. Check family group mapping (OpenAI Series, then Anthropic Series)
|
||||
const familyRoute =
|
||||
resolveOpenAIFamilyRoute(originalModel, lowerModel, openaiMapping) ??
|
||||
resolveAnthropicFamilyRoute(originalModel, lowerModel, anthropicMapping);
|
||||
if (familyRoute !== undefined) {
|
||||
return familyRoute;
|
||||
}
|
||||
|
||||
// GPT-4o / 3.5 Series (Balanced & Lightweight, includes 4o, mini, turbo)
|
||||
if (
|
||||
lowerModel.includes('4o') ||
|
||||
lowerModel.startsWith('gpt-3.5') ||
|
||||
(lowerModel.includes('mini') && !lowerModel.includes('gemini')) ||
|
||||
lowerModel.includes('turbo')
|
||||
) {
|
||||
if (openaiMapping['gpt-4o-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-4o/3.5 series mapping: ${originalModel} -> ${openaiMapping['gpt-4o-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4o-series'];
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-5 Series (gpt-5, gpt-5.1, gpt-5.2, etc.)
|
||||
if (lowerModel.startsWith('gpt-5')) {
|
||||
// Prefer gpt-5-series mapping, fallback to gpt-4-series if missing
|
||||
if (openaiMapping['gpt-5-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-5 series mapping: ${originalModel} -> ${openaiMapping['gpt-5-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-5-series'];
|
||||
}
|
||||
if (openaiMapping['gpt-4-series']) {
|
||||
logger.info(
|
||||
`[Router] Using GPT-4 series mapping (GPT-5 fallback): ${originalModel} -> ${openaiMapping['gpt-4-series']}`,
|
||||
);
|
||||
return openaiMapping['gpt-4-series'];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check family group mapping (Anthropic Series)
|
||||
if (lowerModel.startsWith('claude-')) {
|
||||
let familyKey = 'claude-default';
|
||||
if (lowerModel.includes('4-5') || lowerModel.includes('4.5')) {
|
||||
familyKey = 'claude-4.5-series';
|
||||
} else if (lowerModel.includes('3-5') || lowerModel.includes('3.5')) {
|
||||
familyKey = 'claude-3.5-series';
|
||||
}
|
||||
|
||||
if (anthropicMapping[familyKey]) {
|
||||
logger.warn(
|
||||
`[Router] Using Anthropic series mapping: ${originalModel} -> ${anthropicMapping[familyKey]}`,
|
||||
);
|
||||
return anthropicMapping[familyKey];
|
||||
}
|
||||
|
||||
// Fallback to legacy exact mapping
|
||||
if (anthropicMapping[originalModel]) {
|
||||
return anthropicMapping[originalModel];
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fall through to system default mapping logic
|
||||
// 3. Fall through to system default mapping logic
|
||||
return mapClaudeModelToGemini(originalModel);
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,8 @@ export interface GeminiRequest {
|
||||
systemInstruction?: { parts: { text: string }[] };
|
||||
/** Generation config */
|
||||
generationConfig?: GenerationConfig;
|
||||
/** Function calling mode, emitted by the request mapper */
|
||||
toolConfig?: { functionCallingConfig: { mode: string; allowedFunctionNames?: string[] } };
|
||||
}
|
||||
|
||||
export interface GeminiInternalRequest {
|
||||
|
||||
@@ -405,6 +405,8 @@ print(response.choices[0].message.content)`;
|
||||
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
title={showKey ? t('proxy.config.hide_key') : t('proxy.config.show_key')}
|
||||
aria-label={showKey ? t('proxy.config.hide_key') : t('proxy.config.show_key')}
|
||||
aria-pressed={showKey}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className="text-muted-foreground h-4 w-4" />
|
||||
@@ -537,6 +539,7 @@ print(response.choices[0].message.content)`;
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
||||
aria-label={t('proxy.copy')}
|
||||
onClick={() =>
|
||||
copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`)
|
||||
}
|
||||
@@ -906,6 +909,7 @@ print(response.choices[0].message.content)`;
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('proxy.copy')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(getCurlExample(activeModelTab));
|
||||
@@ -938,6 +942,7 @@ print(response.choices[0].message.content)`;
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t('proxy.copy')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(getPythonExample(activeModelTab));
|
||||
|
||||
@@ -524,6 +524,7 @@ function UsagePage() {
|
||||
size="icon"
|
||||
className="ml-2 h-5 w-5 hover:bg-white/10"
|
||||
onClick={() => setIsPricingDialogOpen(true)}
|
||||
aria-label="Model pricing settings"
|
||||
>
|
||||
<Settings className="text-muted-foreground h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { getServerConfig } from '../server-config';
|
||||
import { extractApiKeyToken, hasConfiguredApiKey, RequestHeaders } from './api-key-auth.util';
|
||||
import {
|
||||
apiKeyMatches,
|
||||
extractApiKeyToken,
|
||||
hasConfiguredApiKey,
|
||||
RequestHeaders,
|
||||
} from './api-key-auth.util';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
@@ -16,7 +21,7 @@ export class AdminGuard implements CanActivate {
|
||||
const headers = request.headers as RequestHeaders;
|
||||
const clientToken = extractApiKeyToken(headers);
|
||||
|
||||
if (clientToken && clientToken === apiKey) {
|
||||
if (apiKeyMatches(clientToken, apiKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, timingSafeEqual } from 'crypto';
|
||||
import { isEmpty, isString } from 'lodash-es';
|
||||
|
||||
export type RequestHeaderValue = string | string[] | undefined;
|
||||
@@ -8,6 +9,20 @@ export function hasConfiguredApiKey(apiKey: string | undefined): apiKey is strin
|
||||
return isString(apiKey) && !isEmpty(apiKey.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the presented key with the configured one without leaking, through response time,
|
||||
* how many leading characters matched. Both values are hashed so the buffers always have the
|
||||
* same length, which timingSafeEqual requires.
|
||||
*/
|
||||
export function apiKeyMatches(clientToken: string | null, apiKey: string): boolean {
|
||||
if (clientToken === null) {
|
||||
return false;
|
||||
}
|
||||
const presented = createHash('sha256').update(clientToken).digest();
|
||||
const expected = createHash('sha256').update(apiKey).digest();
|
||||
return timingSafeEqual(presented, expected);
|
||||
}
|
||||
|
||||
export function extractApiKeyToken(headers: RequestHeaders): string | null {
|
||||
const authorizationHeader = readHeaderValue(headers['authorization']);
|
||||
if (authorizationHeader) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { getServerConfig } from '../../server-config';
|
||||
import {
|
||||
apiKeyMatches,
|
||||
extractApiKeyToken,
|
||||
hasConfiguredApiKey,
|
||||
RequestHeaders,
|
||||
@@ -32,7 +33,7 @@ export class ProxyGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clientToken === apiKey) {
|
||||
if (apiKeyMatches(clientToken, apiKey)) {
|
||||
return true;
|
||||
}
|
||||
this.logger.warn(`Rejected unauthorized request from ${request.ip}`);
|
||||
|
||||
@@ -35,6 +35,7 @@ import { getMaxOutputTokens, getThinkingBudget } from '../../../lib/geminiNexus/
|
||||
import { resolveRequestUserAgent } from './request-user-agent';
|
||||
import { UpstreamRequestError } from './clients/upstream-error';
|
||||
import { trafficLogger } from '../../../utils/traffic-logger';
|
||||
import { TtlCache } from '../../../utils/ttl-cache';
|
||||
import { encode } from 'gpt-tokenizer';
|
||||
|
||||
export interface ProxyRequestContext {
|
||||
@@ -52,14 +53,17 @@ export interface ProxyConfigOverride {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const RESPONSE_CACHE_TTL_MS = 60_000;
|
||||
const RESPONSE_CACHE_MAX_ENTRIES = 100;
|
||||
|
||||
@Injectable()
|
||||
export class ProxyService {
|
||||
private readonly logger = new Logger(ProxyService.name);
|
||||
private readonly responseCache = new Map<
|
||||
string,
|
||||
{ response: OpenAIChatResponse; expiry: number }
|
||||
>();
|
||||
private readonly CACHE_TTL_MS = 60000; // 1 minute
|
||||
/** Bounded: each key is a full request body, so an unlimited cache grows with every unique prompt. */
|
||||
private readonly responseCache = new TtlCache<OpenAIChatResponse>(
|
||||
RESPONSE_CACHE_TTL_MS,
|
||||
RESPONSE_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Inject(TokenManagerService) private readonly tokenManager: TokenManagerService,
|
||||
@@ -2268,19 +2272,11 @@ export class ProxyService {
|
||||
}
|
||||
|
||||
private getCachedResponse(key: string): OpenAIChatResponse | undefined {
|
||||
const entry = this.responseCache.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (Date.now() > entry.expiry) {
|
||||
this.responseCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.response;
|
||||
return this.responseCache.get(key);
|
||||
}
|
||||
|
||||
private setCachedResponse(key: string, response: OpenAIChatResponse): void {
|
||||
this.responseCache.set(key, { response, expiry: Date.now() + this.CACHE_TTL_MS });
|
||||
this.responseCache.set(key, response);
|
||||
}
|
||||
|
||||
private extractAnthropicSessionKey(request: AnthropicChatRequest): string | undefined {
|
||||
|
||||
@@ -44,7 +44,9 @@ function toLowerText(value: string | undefined): string {
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(text: string): number | null {
|
||||
const match = text.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?/i);
|
||||
const match = text
|
||||
.trim()
|
||||
.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -54,9 +56,8 @@ function parseDurationToSeconds(text: string): number | null {
|
||||
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) {
|
||||
const totalSeconds = Math.ceil(hours * 3600 + minutes * 60 + seconds + milliseconds / 1000);
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
return totalSeconds;
|
||||
@@ -93,6 +94,153 @@ function mapGoogleReasonToTrackerReason(reason: string): RateLimitReason | null
|
||||
return null;
|
||||
}
|
||||
|
||||
function getErrorDetails(parsedBody: ParsedGoogleErrorBody | null): GoogleErrorDetail[] {
|
||||
const details = parsedBody?.error?.details;
|
||||
return Array.isArray(details) ? details : [];
|
||||
}
|
||||
|
||||
function detectReasonFromDetails(parsedBody: ParsedGoogleErrorBody | null): RateLimitReason | null {
|
||||
for (const detail of getErrorDetails(parsedBody)) {
|
||||
if (!isString(detail.reason) || isEmpty(detail.reason.trim())) {
|
||||
continue;
|
||||
}
|
||||
const mappedReason = mapGoogleReasonToTrackerReason(detail.reason);
|
||||
if (mappedReason) {
|
||||
return mappedReason;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectReasonFromStatusField(
|
||||
parsedBody: ParsedGoogleErrorBody | null,
|
||||
body: string | undefined,
|
||||
): RateLimitReason | null {
|
||||
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;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Last resort for 429s: guess from free-form error text. */
|
||||
function detectReasonFromText(body: string | undefined): RateLimitReason {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Seconds from a Retry-After header (delta-seconds or HTTP-date), or null when unusable. */
|
||||
function parseRetryAfterHeader(rawHeader: string | undefined): number | null {
|
||||
const headerRetryRaw = rawHeader?.trim() ?? '';
|
||||
if (headerRetryRaw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerRetry = Number(headerRetryRaw);
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDefaultRetrySeconds(params: {
|
||||
reason: RateLimitReason;
|
||||
status: number;
|
||||
failureCount: number;
|
||||
backoffSteps: number[];
|
||||
}): number {
|
||||
switch (params.reason) {
|
||||
case RateLimitReason.QuotaExhausted: {
|
||||
const steps = params.backoffSteps.length > 0 ? params.backoffSteps : [60, 300, 1800, 7200];
|
||||
const index = Math.max(0, params.failureCount - 1);
|
||||
return steps[Math.min(index, steps.length - 1)];
|
||||
}
|
||||
case RateLimitReason.RateLimitExceeded:
|
||||
return 5;
|
||||
case RateLimitReason.ModelCapacityExhausted:
|
||||
if (params.failureCount <= 1) {
|
||||
return 5;
|
||||
}
|
||||
return params.failureCount === 2 ? 10 : 15;
|
||||
case RateLimitReason.ServerError:
|
||||
return params.status === 404 ? 5 : 8;
|
||||
default:
|
||||
return 60;
|
||||
}
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | null {
|
||||
return isString(value) && !isEmpty(value.trim()) ? value : null;
|
||||
}
|
||||
|
||||
function parseRetryDelayFromDetails(parsedBody: ParsedGoogleErrorBody | null): number | null {
|
||||
for (const detail of getErrorDetails(parsedBody)) {
|
||||
const candidates = [
|
||||
detail.retryDelay,
|
||||
detail.metadata?.retryDelay,
|
||||
detail.metadata?.quotaResetDelay,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const delayText = nonBlankString(candidate);
|
||||
const parsedDelay = delayText === null ? null : parseDurationToSeconds(delayText);
|
||||
if (parsedDelay !== null) {
|
||||
return parsedDelay;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRetryDelayFromText(body: string | undefined): number | null {
|
||||
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;
|
||||
}
|
||||
|
||||
export class RateLimitTracker {
|
||||
private readonly lockoutByKey = new Map<string, RateLimitInfo>();
|
||||
private readonly failureCounts = new Map<string, FailureCountEntry>();
|
||||
@@ -108,19 +256,16 @@ export class RateLimitTracker {
|
||||
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));
|
||||
}
|
||||
let resetTimeMs = globalLock?.resetTimeMs ?? now;
|
||||
|
||||
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));
|
||||
}
|
||||
resetTimeMs = Math.max(resetTimeMs, modelLock?.resetTimeMs ?? now);
|
||||
}
|
||||
|
||||
return 0;
|
||||
// Both scopes apply; retrying before either lock expires still fails upstream.
|
||||
return Math.max(0, Math.ceil((resetTimeMs - now) / 1000));
|
||||
}
|
||||
|
||||
getRemainingWaitSec(accountId: string, model?: string): number {
|
||||
@@ -246,52 +391,15 @@ export class RateLimitTracker {
|
||||
|
||||
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 structuredReason =
|
||||
detectReasonFromDetails(parsedBody) ?? detectReasonFromStatusField(parsedBody, body);
|
||||
if (structuredReason) {
|
||||
return structuredReason;
|
||||
}
|
||||
|
||||
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;
|
||||
return detectReasonFromText(body);
|
||||
}
|
||||
|
||||
private computeRetryAfterSeconds(params: {
|
||||
@@ -302,20 +410,9 @@ export class RateLimitTracker {
|
||||
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 headerRetry = parseRetryAfterHeader(params.retryAfter);
|
||||
if (headerRetry !== null) {
|
||||
return headerRetry;
|
||||
}
|
||||
|
||||
const bodyRetry = this.parseRetryAfterSecondsFromBody(params.body);
|
||||
@@ -327,32 +424,12 @@ export class RateLimitTracker {
|
||||
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;
|
||||
return resolveDefaultRetrySeconds({
|
||||
reason: params.reason,
|
||||
status: params.status,
|
||||
failureCount,
|
||||
backoffSteps: params.backoffSteps,
|
||||
});
|
||||
}
|
||||
|
||||
private parseRetryAfterSecondsFromBody(body: string | undefined): number | null {
|
||||
@@ -360,31 +437,9 @@ export class RateLimitTracker {
|
||||
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 detailDelay = parseRetryDelayFromDetails(parsedBody);
|
||||
if (detailDelay !== null) {
|
||||
return detailDelay;
|
||||
}
|
||||
|
||||
const retryAfter = parsedBody?.error?.retry_after;
|
||||
@@ -392,23 +447,7 @@ export class RateLimitTracker {
|
||||
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;
|
||||
return parseRetryDelayFromText(body);
|
||||
}
|
||||
|
||||
private incrementFailureCount(accountId: string): number {
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { transformClaudeRequestIn } from '../../lib/geminiNexus/ClaudeRequestMapper';
|
||||
import { SignatureStore } from '../../lib/geminiNexus/SignatureStore';
|
||||
import type { ClaudeRequest, Message } from '../../lib/geminiNexus/types';
|
||||
|
||||
const FALLBACK_SIGNATURE = 'skip_thought_signature_validator';
|
||||
|
||||
function makeRequest(overrides: Record<string, unknown> = {}): ClaudeRequest {
|
||||
return {
|
||||
model: 'gemini-3-flash',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
max_tokens: 100,
|
||||
...overrides,
|
||||
} as unknown as ClaudeRequest;
|
||||
}
|
||||
|
||||
function asMessages(messages: unknown[]): Message[] {
|
||||
return messages as Message[];
|
||||
}
|
||||
|
||||
function contentsOf(messages: unknown[], overrides: Record<string, unknown> = {}) {
|
||||
return transformClaudeRequestIn(
|
||||
makeRequest({ messages: asMessages(messages), thinking: { type: 'disabled' }, ...overrides }),
|
||||
'project',
|
||||
).request.contents;
|
||||
}
|
||||
|
||||
function generationConfigOf(overrides: Record<string, unknown> = {}) {
|
||||
return transformClaudeRequestIn(makeRequest(overrides), 'project').request.generationConfig;
|
||||
}
|
||||
|
||||
describe('transformClaudeRequestIn - contents', () => {
|
||||
beforeEach(() => {
|
||||
SignatureStore.clear();
|
||||
});
|
||||
|
||||
it('maps a plain string message to a user text part', () => {
|
||||
expect(contentsOf([{ role: 'user', content: 'Hello' }])).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps assistant to model and drops empty-string content', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: '' },
|
||||
{ role: 'assistant', content: 'Answer' },
|
||||
]);
|
||||
expect(contents.map((c) => c.role)).toEqual(['user', 'model']);
|
||||
expect(contents[1].parts).toEqual([{ text: 'Answer' }]);
|
||||
});
|
||||
|
||||
it('skips blank text blocks and the "(no content)" placeholder', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'text', text: '(no content)' },
|
||||
{ type: 'text', text: ' kept ' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(contents).toEqual([{ role: 'user', parts: [{ text: 'kept' }] }]);
|
||||
});
|
||||
|
||||
it('prefixes function results and named messages, treating function role as user', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'function', name: 'lookup', content: 'result' },
|
||||
{ role: 'user', name: 'bob', content: 'hey' },
|
||||
]);
|
||||
expect(contents[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Function result (lookup):\nresult' }],
|
||||
});
|
||||
expect(contents[1].parts).toEqual([{ text: '[bob]: hey' }]);
|
||||
});
|
||||
|
||||
it('keeps base64 images and ignores non-base64 image sources', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } },
|
||||
{ type: 'image', source: { type: 'url', url: 'https://example.com/a.png' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(contents).toEqual([
|
||||
{ role: 'user', parts: [{ inlineData: { mimeType: 'image/png', data: 'AAAA' } }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the block signature for thinking blocks and remembers it for tool calls', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'hmm', signature: 'sig-from-block' },
|
||||
{ type: 'tool_use', id: 'call-1', name: 'search', input: { q: 'x' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const parts = contents[0].parts as any[];
|
||||
expect(parts[0]).toMatchObject({
|
||||
text: 'hmm',
|
||||
thought: true,
|
||||
thoughtSignature: 'sig-from-block',
|
||||
});
|
||||
expect(parts[1].functionCall).toEqual({ name: 'search', args: { q: 'x' }, id: 'call-1' });
|
||||
expect(parts[1].thoughtSignature).toBe('sig-from-block');
|
||||
});
|
||||
|
||||
it('falls back to the global signature, then to the validator bypass, for thinking blocks', () => {
|
||||
const messages = [{ role: 'assistant', content: [{ type: 'thinking', thinking: 'hmm' }] }];
|
||||
expect((contentsOf(messages)[0].parts[0] as any).thoughtSignature).toBe(FALLBACK_SIGNATURE);
|
||||
|
||||
SignatureStore.store('global-signature');
|
||||
expect((contentsOf(messages)[0].parts[0] as any).thoughtSignature).toBe('global-signature');
|
||||
});
|
||||
|
||||
it('prefers the tool_use own signature over the global one', () => {
|
||||
SignatureStore.store('global-signature-longer');
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 't', name: 'n', input: {}, signature: 'own' }],
|
||||
},
|
||||
]);
|
||||
expect((contents[0].parts[0] as any).thoughtSignature).toBe('own');
|
||||
});
|
||||
|
||||
it('uses the validator bypass for tool_use when no signature is available', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 't', name: 'n', input: {} }],
|
||||
},
|
||||
]);
|
||||
expect((contents[0].parts[0] as any).thoughtSignature).toBe(FALLBACK_SIGNATURE);
|
||||
});
|
||||
|
||||
it('resolves tool_result names from earlier tool_use ids, falling back to the id', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'call-1', name: 'search', input: {} }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'call-1', content: 'found' },
|
||||
{ type: 'tool_result', tool_use_id: 'unknown-id', content: 'orphan' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const parts = contents[1].parts as any[];
|
||||
expect(parts[0].functionResponse).toEqual({
|
||||
name: 'search',
|
||||
response: { result: 'found' },
|
||||
id: 'call-1',
|
||||
});
|
||||
expect(parts[1].functionResponse.name).toBe('unknown-id');
|
||||
});
|
||||
|
||||
it('joins text items of array tool_result content and ignores other item types', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'x',
|
||||
content: [
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'image', source: {} },
|
||||
{ type: 'text', text: 'b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect((contents[0].parts[0] as any).functionResponse.response).toEqual({ result: 'a\nb' });
|
||||
});
|
||||
|
||||
it('uses default messages for empty tool results, distinguishing errors', () => {
|
||||
const contents = contentsOf([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'ok', content: ' ' },
|
||||
{ type: 'tool_result', tool_use_id: 'bad', content: '', is_error: true },
|
||||
{ type: 'tool_result', tool_use_id: 'boom', content: 'exploded', is_error: true },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const parts = contents[0].parts as any[];
|
||||
expect(parts[0].functionResponse.response).toEqual({
|
||||
result: 'Command executed successfully.',
|
||||
});
|
||||
expect(parts[1].functionResponse.response).toEqual({
|
||||
error: 'Tool execution failed with no output.',
|
||||
});
|
||||
expect(parts[2].functionResponse.response).toEqual({ error: 'exploded' });
|
||||
expect(parts[0].thoughtSignature).toBe(FALLBACK_SIGNATURE);
|
||||
});
|
||||
|
||||
it('keeps redacted thinking as a marked thought part', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'assistant', content: [{ type: 'redacted_thinking', data: 'abc' }] },
|
||||
]);
|
||||
expect(contents[0].parts).toEqual([{ text: '[Redacted Thinking: abc]', thought: true }]);
|
||||
});
|
||||
|
||||
it('drops messages that produce no parts', () => {
|
||||
const contents = contentsOf([
|
||||
{ role: 'user', content: [{ type: 'text', text: ' ' }] },
|
||||
{ role: 'user', content: 'real' },
|
||||
]);
|
||||
expect(contents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('prepends a dummy thought to a trailing model turn without thinking when thinking is on', () => {
|
||||
const contents = contentsOf(
|
||||
[
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'Partial' },
|
||||
],
|
||||
{ thinking: { type: 'enabled' } },
|
||||
);
|
||||
expect(contents[1].parts[0]).toEqual({ text: 'Thinking...', thought: true });
|
||||
expect(contents[1].parts[1]).toEqual({ text: 'Partial' });
|
||||
});
|
||||
|
||||
it('does not add a dummy thought when the model turn already has a thought', () => {
|
||||
const contents = contentsOf(
|
||||
[
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'real', signature: 's' },
|
||||
{ type: 'text', text: 'Partial' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ thinking: { type: 'enabled' } },
|
||||
);
|
||||
expect(contents[1].parts).toHaveLength(2);
|
||||
expect((contents[1].parts[0] as any).text).toBe('real');
|
||||
});
|
||||
|
||||
it('does not add a dummy thought when thinking is off or the model turn is not last', () => {
|
||||
const off = contentsOf(
|
||||
[
|
||||
{ role: 'user', content: 'Hi' },
|
||||
{ role: 'assistant', content: 'Partial' },
|
||||
],
|
||||
{ thinking: { type: 'disabled' } },
|
||||
);
|
||||
expect(off[1].parts).toEqual([{ text: 'Partial' }]);
|
||||
|
||||
const notLast = contentsOf(
|
||||
[
|
||||
{ role: 'assistant', content: 'First' },
|
||||
{ role: 'user', content: 'Hi' },
|
||||
],
|
||||
{ thinking: { type: 'enabled' } },
|
||||
);
|
||||
expect(notLast[0].parts).toEqual([{ text: 'First' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformClaudeRequestIn - generationConfig', () => {
|
||||
beforeEach(() => {
|
||||
SignatureStore.clear();
|
||||
});
|
||||
|
||||
const DEFAULT_STOP_SEQUENCES = [
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'[DONE]',
|
||||
'\n\nHuman:',
|
||||
];
|
||||
|
||||
it('native path: forwards sampling options and merges default stop sequences', () => {
|
||||
const config = generationConfigOf({
|
||||
temperature: 0.2,
|
||||
top_p: 0.5,
|
||||
top_k: 10,
|
||||
max_tokens: 321,
|
||||
stop_sequences: ['STOP', '[DONE]'],
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config).toMatchObject({ temperature: 0.2, topP: 0.5, topK: 10, maxOutputTokens: 321 });
|
||||
expect(config?.stopSequences).toEqual([
|
||||
'STOP',
|
||||
'[DONE]',
|
||||
'<|user|>',
|
||||
'<|endoftext|>',
|
||||
'<|end_of_turn|>',
|
||||
'\n\nHuman:',
|
||||
]);
|
||||
expect(config?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('native path: uses only the default stop sequences and omits unset options', () => {
|
||||
const config = generationConfigOf({ max_tokens: undefined, thinking: { type: 'disabled' } });
|
||||
expect(config?.stopSequences).toEqual(DEFAULT_STOP_SEQUENCES);
|
||||
expect(config).not.toHaveProperty('temperature');
|
||||
expect(config).not.toHaveProperty('topP');
|
||||
expect(config).not.toHaveProperty('topK');
|
||||
expect(config).not.toHaveProperty('maxOutputTokens');
|
||||
});
|
||||
|
||||
it('max_completion_tokens takes precedence over max_tokens', () => {
|
||||
const config = generationConfigOf({
|
||||
max_completion_tokens: 55,
|
||||
max_tokens: 99,
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config?.maxOutputTokens).toBe(55);
|
||||
});
|
||||
|
||||
it('openai path: applies temperature/topP defaults and model max output tokens', () => {
|
||||
const config = generationConfigOf({
|
||||
metadata: { source: 'openai' },
|
||||
max_tokens: undefined,
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config?.temperature).toBe(1.0);
|
||||
expect(config?.topP).toBe(0.95);
|
||||
expect(config?.maxOutputTokens).toBeGreaterThan(0);
|
||||
expect(config).not.toHaveProperty('stopSequences');
|
||||
expect(config).not.toHaveProperty('topK');
|
||||
});
|
||||
|
||||
it('openai path: keeps explicit values and only custom stop sequences', () => {
|
||||
const config = generationConfigOf({
|
||||
metadata: { source: 'OpenAI' },
|
||||
temperature: 0,
|
||||
top_p: 0.1,
|
||||
max_tokens: 42,
|
||||
stop_sequences: ['END'],
|
||||
thinking: { type: 'disabled' },
|
||||
});
|
||||
expect(config).toMatchObject({
|
||||
temperature: 0,
|
||||
topP: 0.1,
|
||||
maxOutputTokens: 42,
|
||||
stopSequences: ['END'],
|
||||
});
|
||||
});
|
||||
|
||||
it('thinking: explicit budget is capped for flash models and kept otherwise', () => {
|
||||
const flash = generationConfigOf({ thinking: { type: 'enabled', budget_tokens: 50000 } });
|
||||
expect(flash?.thinkingConfig).toEqual({ includeThoughts: true, thinkingBudget: 24576 });
|
||||
|
||||
const flashSmall = generationConfigOf({ thinking: { type: 'enabled', budget_tokens: 1000 } });
|
||||
expect(flashSmall?.thinkingConfig?.thinkingBudget).toBe(1000);
|
||||
|
||||
const pro = generationConfigOf({
|
||||
model: 'gemini-3.1-pro-high',
|
||||
thinking: { type: 'enabled', budget_tokens: 50000 },
|
||||
});
|
||||
expect(pro?.thinkingConfig?.thinkingBudget).toBe(50000);
|
||||
});
|
||||
|
||||
it('thinking: without explicit budget uses the model default budget', () => {
|
||||
const config = generationConfigOf({ thinking: { type: 'enabled' } });
|
||||
expect(config?.thinkingConfig?.includeThoughts).toBe(true);
|
||||
expect(typeof config?.thinkingConfig?.thinkingBudget).toBe('number');
|
||||
});
|
||||
|
||||
it('thinking: adaptive uses a fixed budget on non-Claude models', () => {
|
||||
const config = generationConfigOf({ thinking: { type: 'adaptive', effort: 'low' } });
|
||||
expect(config?.thinkingConfig).toEqual({ includeThoughts: true, thinkingBudget: 24576 });
|
||||
});
|
||||
|
||||
it('thinking: enabled by default for gemini-3-flash and off when explicitly disabled', () => {
|
||||
expect(generationConfigOf()?.thinkingConfig?.includeThoughts).toBe(true);
|
||||
expect(generationConfigOf({ thinking: { type: 'disabled' } })?.thinkingConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('response_format: json_object sets the mime type only', () => {
|
||||
const config = generationConfigOf({ response_format: { type: 'json_object' } });
|
||||
expect(config?.responseMimeType).toBe('application/json');
|
||||
expect(config).not.toHaveProperty('responseSchema');
|
||||
});
|
||||
|
||||
it('response_format: json_schema sets the mime type; incomplete or text sets nothing', () => {
|
||||
const withSchema = generationConfigOf({
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: { type: 'object', properties: { a: { type: 'string' } } },
|
||||
},
|
||||
});
|
||||
expect(withSchema?.responseMimeType).toBe('application/json');
|
||||
// Known limitation: cleanJsonSchema() mutates in place and returns void, so the schema
|
||||
// assigned to responseSchema is undefined and dropped from the request.
|
||||
expect(withSchema?.responseSchema).toBeUndefined();
|
||||
|
||||
const missingSchema = generationConfigOf({ response_format: { type: 'json_schema' } });
|
||||
expect(missingSchema).not.toHaveProperty('responseMimeType');
|
||||
|
||||
const text = generationConfigOf({ response_format: { type: 'text' } });
|
||||
expect(text).not.toHaveProperty('responseMimeType');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformClaudeRequestIn - request assembly', () => {
|
||||
beforeEach(() => {
|
||||
SignatureStore.clear();
|
||||
});
|
||||
|
||||
const FUNCTION_TOOL = {
|
||||
name: 'lookup',
|
||||
description: 'Look something up',
|
||||
input_schema: { type: 'object', properties: { q: { type: 'string' } } },
|
||||
};
|
||||
|
||||
function transform(overrides: Record<string, unknown> = {}, projectId?: string) {
|
||||
return transformClaudeRequestIn(makeRequest(overrides), projectId);
|
||||
}
|
||||
|
||||
it('defaults to an agent request with safety settings and a trimmed project id', () => {
|
||||
const body = transform({}, ' my-project ');
|
||||
expect(body.requestType).toBe('agent');
|
||||
expect(body.project).toBe('my-project');
|
||||
expect(body.model).toBe('gemini-3-flash');
|
||||
expect(body.requestId).toMatch(/^agent-/);
|
||||
expect(body.request.safetySettings).toHaveLength(5);
|
||||
expect(body.request.safetySettings?.every((s) => s.threshold === 'OFF')).toBe(true);
|
||||
expect(body.request).not.toHaveProperty('tools');
|
||||
expect(body.request).not.toHaveProperty('toolConfig');
|
||||
});
|
||||
|
||||
it('omits project and sessionId when absent and forwards metadata.user_id as sessionId', () => {
|
||||
const bare = transform();
|
||||
expect(bare).not.toHaveProperty('project');
|
||||
expect(bare).not.toHaveProperty('sessionId');
|
||||
|
||||
const blankProject = transform({}, ' ');
|
||||
expect(blankProject).not.toHaveProperty('project');
|
||||
|
||||
const withSession = transform({ metadata: { user_id: 'session-1' } });
|
||||
expect(withSession.sessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
it('uses a caller supplied user agent after trimming', () => {
|
||||
const body = transformClaudeRequestIn(makeRequest(), 'p', ' custom-agent/1.0 ');
|
||||
expect(body.userAgent).toBe('custom-agent/1.0');
|
||||
expect(transformClaudeRequestIn(makeRequest(), 'p', ' ').userAgent).toBeTruthy();
|
||||
});
|
||||
|
||||
it('system prompt: string and text blocks become parts; empty system is omitted', () => {
|
||||
expect(transform({ system: 'Be brief' }).request.systemInstruction?.parts).toEqual([
|
||||
{ text: 'Be brief' },
|
||||
]);
|
||||
expect(
|
||||
transform({
|
||||
system: [
|
||||
{ type: 'text', text: 'one' },
|
||||
{ type: 'other', text: 'skipped' },
|
||||
{ type: 'text', text: 'two' },
|
||||
],
|
||||
}).request.systemInstruction?.parts,
|
||||
).toEqual([{ text: 'one' }, { text: 'two' }]);
|
||||
expect(transform().request).not.toHaveProperty('systemInstruction');
|
||||
});
|
||||
|
||||
it('declares function tools and defaults tool_choice to AUTO', () => {
|
||||
const body = transform({ tools: [FUNCTION_TOOL] });
|
||||
expect(body.request.tools?.[0].functionDeclarations?.[0].name).toBe('lookup');
|
||||
expect(body.request.toolConfig).toEqual({ functionCallingConfig: { mode: 'AUTO' } });
|
||||
});
|
||||
|
||||
it('tool_choice: auto stays AUTO, none disables, named function forces ANY', () => {
|
||||
expect(transform({ tools: [FUNCTION_TOOL], tool_choice: 'auto' }).request.toolConfig).toEqual({
|
||||
functionCallingConfig: { mode: 'AUTO' },
|
||||
});
|
||||
expect(transform({ tools: [FUNCTION_TOOL], tool_choice: 'none' }).request.toolConfig).toEqual({
|
||||
functionCallingConfig: { mode: 'NONE' },
|
||||
});
|
||||
expect(
|
||||
transform({
|
||||
tools: [FUNCTION_TOOL],
|
||||
tool_choice: { type: 'function', function: { name: 'lookup' } },
|
||||
}).request.toolConfig,
|
||||
).toEqual({ functionCallingConfig: { mode: 'ANY', allowedFunctionNames: ['lookup'] } });
|
||||
});
|
||||
|
||||
it('tool_choice: a function choice without a name falls back to AUTO', () => {
|
||||
const body = transform({
|
||||
tools: [FUNCTION_TOOL],
|
||||
tool_choice: { type: 'function', function: { name: '' } },
|
||||
});
|
||||
expect(body.request.toolConfig).toEqual({ functionCallingConfig: { mode: 'AUTO' } });
|
||||
});
|
||||
|
||||
it('tool_choice without tools: only none produces a toolConfig', () => {
|
||||
expect(transform({ tool_choice: 'none' }).request.toolConfig).toEqual({
|
||||
functionCallingConfig: { mode: 'NONE' },
|
||||
});
|
||||
expect(transform({ tool_choice: 'auto' }).request).not.toHaveProperty('toolConfig');
|
||||
expect(
|
||||
transform({ tool_choice: { type: 'function', function: { name: 'lookup' } } }).request,
|
||||
).not.toHaveProperty('toolConfig');
|
||||
});
|
||||
|
||||
it('web search tool switches to a web_search request on gemini-3-flash with googleSearch', () => {
|
||||
const body = transform({
|
||||
model: 'gemini-3.1-pro-high',
|
||||
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
|
||||
});
|
||||
expect(body.requestType).toBe('web_search');
|
||||
expect(body.model).toBe('gemini-3-flash');
|
||||
expect(body.request.tools).toEqual([{ googleSearch: {} }]);
|
||||
});
|
||||
|
||||
it('-online suffix enables networking; function declarations suppress googleSearch', () => {
|
||||
const online = transform({ model: 'gemini-3-flash-online' });
|
||||
expect(online.requestType).toBe('web_search');
|
||||
expect(online.model).toBe('gemini-3-flash');
|
||||
expect(online.request.tools).toEqual([{ googleSearch: {} }]);
|
||||
|
||||
const withFunctions = transform({ model: 'gemini-3-flash-online', tools: [FUNCTION_TOOL] });
|
||||
expect(withFunctions.requestType).toBe('web_search');
|
||||
expect(withFunctions.request.tools?.every((t) => !t.googleSearch)).toBe(true);
|
||||
});
|
||||
|
||||
it('image models: strip tools, system prompt and thinking, and attach imageConfig', () => {
|
||||
const body = transform({
|
||||
model: 'gemini-3-pro-image-16x9-4k',
|
||||
system: 'ignored',
|
||||
tools: [FUNCTION_TOOL],
|
||||
response_format: { type: 'json_object' },
|
||||
});
|
||||
expect(body.requestType).toBe('image_gen');
|
||||
expect(body.request).not.toHaveProperty('tools');
|
||||
expect(body.request).not.toHaveProperty('systemInstruction');
|
||||
expect(body.request.generationConfig?.imageConfig).toEqual({
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '4K',
|
||||
});
|
||||
expect(body.request.generationConfig).not.toHaveProperty('thinkingConfig');
|
||||
expect(body.request.generationConfig).not.toHaveProperty('responseMimeType');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { redactCloudAccountTokens } from '../../utils/cloud-account-redaction';
|
||||
import type { CloudAccount } from '../../types/cloudAccount';
|
||||
|
||||
function makeAccount(): CloudAccount {
|
||||
return {
|
||||
id: 'acc-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
name: 'User',
|
||||
token: {
|
||||
access_token: 'ya29.secret-access',
|
||||
refresh_token: '1//secret-refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: 1_900_000_000,
|
||||
token_type: 'Bearer',
|
||||
project_id: 'project-1',
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: 1,
|
||||
last_used: 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe('redactCloudAccountTokens', () => {
|
||||
it('blanks the OAuth credentials and keeps the non-secret token metadata', () => {
|
||||
const redacted = redactCloudAccountTokens(makeAccount());
|
||||
expect(redacted.token.access_token).toBe('');
|
||||
expect(redacted.token.refresh_token).toBe('');
|
||||
expect(redacted.token).toMatchObject({
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: 1_900_000_000,
|
||||
token_type: 'Bearer',
|
||||
project_id: 'project-1',
|
||||
is_gcp_tos: true,
|
||||
});
|
||||
expect(redacted.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('does not mutate the original account', () => {
|
||||
const account = makeAccount();
|
||||
redactCloudAccountTokens(account);
|
||||
expect(account.token.refresh_token).toBe('1//secret-refresh');
|
||||
expect(account.token.access_token).toBe('ya29.secret-access');
|
||||
});
|
||||
|
||||
it('never leaves a credential in the serialized payload', () => {
|
||||
const payload = JSON.stringify(redactCloudAccountTokens(makeAccount()));
|
||||
expect(payload).not.toContain('secret-access');
|
||||
expect(payload).not.toContain('secret-refresh');
|
||||
});
|
||||
});
|
||||
@@ -1,437 +1,439 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isString } from 'lodash-es';
|
||||
|
||||
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
|
||||
resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'),
|
||||
buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'),
|
||||
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
FALLBACK_VERSION: '1.22.2',
|
||||
}));
|
||||
|
||||
// Mock module to test timeout behavior
|
||||
describe('GoogleAPIService Timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should abort request after timeout', async () => {
|
||||
const TIMEOUT_MS = 100;
|
||||
|
||||
// Recreate the same logic used in GoogleAPIService
|
||||
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||
|
||||
// Verify signal is not aborted initially
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should set aborted state when controller.abort() is called', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Initially not aborted
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
|
||||
// Abort the controller
|
||||
controller.abort();
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should transform AbortError to user-friendly message', () => {
|
||||
// This tests our error transformation logic from GoogleAPIService
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
expect(() => handleAbortError(abortError)).toThrow(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transform non-AbortError', () => {
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const networkError = new Error('Network failure');
|
||||
networkError.name = 'NetworkError';
|
||||
|
||||
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.some((client) => client.is_builtin)).toBe(true);
|
||||
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',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
verified_email: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 fetchCredits when loadCodeAssist returns no usable credits', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No paidTier.availableCredits — no usable credits
|
||||
freeTier: {},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
}),
|
||||
});
|
||||
|
||||
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.toEqual({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
});
|
||||
|
||||
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: 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');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
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: 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');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
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/geminiNexus/QuotaService');
|
||||
|
||||
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
|
||||
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { isString } from 'lodash-es';
|
||||
|
||||
vi.mock('@/server/modules/proxy/request-user-agent', () => ({
|
||||
resolveRequestUserAgent: vi.fn().mockResolvedValue('test-agent/1.0.0'),
|
||||
buildUserAgent: vi.fn().mockReturnValue('test-agent/1.0.0'),
|
||||
resolveLocalInstalledVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||
FALLBACK_VERSION: '1.22.2',
|
||||
}));
|
||||
|
||||
// Mock module to test timeout behavior
|
||||
describe('GoogleAPIService Timeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should abort request after timeout', async () => {
|
||||
const TIMEOUT_MS = 100;
|
||||
|
||||
// Recreate the same logic used in GoogleAPIService
|
||||
const createTimeoutSignal = (ms: number): AbortSignal => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller.signal;
|
||||
};
|
||||
|
||||
const signal = createTimeoutSignal(TIMEOUT_MS);
|
||||
|
||||
// Verify signal is not aborted initially
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
// Fast-forward time
|
||||
vi.advanceTimersByTime(TIMEOUT_MS + 10);
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should set aborted state when controller.abort() is called', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Initially not aborted
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
|
||||
// Abort the controller
|
||||
controller.abort();
|
||||
|
||||
// Signal should now be aborted
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('should transform AbortError to user-friendly message', () => {
|
||||
// This tests our error transformation logic from GoogleAPIService
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
|
||||
expect(() => handleAbortError(abortError)).toThrow(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transform non-AbortError', () => {
|
||||
const handleAbortError = (err: Error) => {
|
||||
if (err.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'Token exchange timed out. Please check your network connection and try again.',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
|
||||
const networkError = new Error('Network failure');
|
||||
networkError.name = 'NetworkError';
|
||||
|
||||
expect(() => handleAbortError(networkError)).toThrow('Network failure');
|
||||
});
|
||||
});
|
||||
|
||||
// Each test resets the module registry and re-imports GoogleAPIService, which can exceed the default
|
||||
// 5s timeout while the whole suite runs in parallel.
|
||||
describe('GoogleAPIService OAuth clients', { timeout: 30_000 }, () => {
|
||||
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.some((client) => client.is_builtin)).toBe(true);
|
||||
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: 'user@example.com',
|
||||
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: 'user@example.com',
|
||||
name: 'Example User',
|
||||
given_name: 'Example',
|
||||
picture: 'https://example.com/avatar.png',
|
||||
verified_email: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 fetchCredits when loadCodeAssist returns no usable credits', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No paidTier.availableCredits — no usable credits
|
||||
freeTier: {},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
}),
|
||||
});
|
||||
|
||||
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.toEqual({
|
||||
credits: 500,
|
||||
expiryDate: '2025-12-31',
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
|
||||
);
|
||||
});
|
||||
|
||||
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: 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');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
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: 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');
|
||||
|
||||
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
|
||||
credits: 1000,
|
||||
expiryDate: '',
|
||||
});
|
||||
|
||||
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/geminiNexus/QuotaService');
|
||||
|
||||
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
|
||||
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
|
||||
);
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { networkInterfaces } from 'os';
|
||||
import {
|
||||
getRecentTrafficLogs,
|
||||
getTrafficLogStats,
|
||||
@@ -14,6 +15,16 @@ import {
|
||||
import { TrafficLogsRepo } from '../../../ipc/database/proxyMetricsHandler';
|
||||
import { proxyAdvancedRegistry } from '../../../ipc/proxy-advanced/service-registry';
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('os')>();
|
||||
const mockedNetworkInterfaces = vi.fn().mockReturnValue({});
|
||||
return {
|
||||
...actual,
|
||||
default: { ...actual, networkInterfaces: mockedNetworkInterfaces },
|
||||
networkInterfaces: mockedNetworkInterfaces,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
@@ -67,6 +78,7 @@ vi.mock('../../../ipc/proxy-advanced/service-registry', () => {
|
||||
describe('Proxy Advanced Handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networkInterfaces).mockReset().mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('getRecentTrafficLogs', () => {
|
||||
@@ -415,9 +427,38 @@ describe('Proxy Advanced Handler', () => {
|
||||
});
|
||||
|
||||
describe('generateIdeConfig', () => {
|
||||
it('uses loopback when network enumeration fails', async () => {
|
||||
vi.mocked(networkInterfaces).mockImplementationOnce(() => {
|
||||
throw new Error('network enumeration denied');
|
||||
});
|
||||
const result = await generateIdeConfig('vscode');
|
||||
expect(networkInterfaces).toHaveBeenCalledOnce();
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.data?.content).toMatchObject({ 'openai.url': 'http://127.0.0.1:8045/v1' });
|
||||
});
|
||||
|
||||
it('uses the discovered LAN address when enumeration succeeds', async () => {
|
||||
vi.mocked(networkInterfaces).mockReturnValueOnce({
|
||||
eth0: [
|
||||
{
|
||||
address: '192.0.2.10',
|
||||
netmask: '255.255.255.0',
|
||||
family: 'IPv4',
|
||||
mac: '00:11:22:33:44:55',
|
||||
internal: false,
|
||||
cidr: '192.0.2.10/24',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await generateIdeConfig('vscode');
|
||||
expect(networkInterfaces).toHaveBeenCalledOnce();
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.data?.content).toMatchObject({ 'openai.url': 'http://192.0.2.10:8045/v1' });
|
||||
});
|
||||
|
||||
it('should generate fallback config when proxyIdeConfig is null', async () => {
|
||||
const result = await generateIdeConfig('vscode');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.data?.name).toBe('VS Code');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getDynamicForwardingTarget,
|
||||
resolveModelRoute,
|
||||
updateDynamicForwardingRules,
|
||||
} from '../../lib/geminiNexus/ModelMapping';
|
||||
|
||||
describe('resolveModelRoute', () => {
|
||||
const NO_MAPPING: Record<string, string> = {};
|
||||
|
||||
function route(
|
||||
model: string,
|
||||
options: {
|
||||
custom?: Record<string, string>;
|
||||
openai?: Record<string, string>;
|
||||
anthropic?: Record<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
return resolveModelRoute(
|
||||
model,
|
||||
options.custom ?? NO_MAPPING,
|
||||
options.openai ?? NO_MAPPING,
|
||||
options.anthropic ?? NO_MAPPING,
|
||||
);
|
||||
}
|
||||
|
||||
describe('dynamic forwarding', () => {
|
||||
it('forwards a deprecated model to a supported replacement', () => {
|
||||
updateDynamicForwardingRules('route-test-old-model', 'gemini-3-flash');
|
||||
expect(getDynamicForwardingTarget('route-test-old-model')).toBe('gemini-3-flash');
|
||||
expect(route('route-test-old-model', { custom: { 'route-test-old-model': 'custom' } })).toBe(
|
||||
'gemini-3-flash',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a forwarding rule whose target is not a supported model', () => {
|
||||
updateDynamicForwardingRules('route-test-unsupported', 'not-a-supported-model');
|
||||
expect(route('route-test-unsupported')).toBe('route-test-unsupported');
|
||||
});
|
||||
|
||||
it('ignores empty or non-string rules', () => {
|
||||
updateDynamicForwardingRules(' ', 'gemini-3-flash');
|
||||
updateDynamicForwardingRules('route-test-empty-target', ' ');
|
||||
expect(getDynamicForwardingTarget('route-test-empty-target')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom exact mapping', () => {
|
||||
it('wins over family mappings and the built-in table', () => {
|
||||
expect(
|
||||
route('gpt-4o', {
|
||||
custom: { 'gpt-4o': 'my-model' },
|
||||
openai: { 'gpt-4o-series': 'other' },
|
||||
}),
|
||||
).toBe('my-model');
|
||||
});
|
||||
|
||||
it('is case sensitive and does not apply to other names', () => {
|
||||
expect(route('GPT-4O', { custom: { 'gpt-4o': 'my-model' } })).toBe('GPT-4O');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI families', () => {
|
||||
it('maps the GPT-4 series (classic, o1-, o3-) when configured', () => {
|
||||
const openai = { 'gpt-4-series': 'gpt4-target' };
|
||||
expect(route('gpt-4', { openai })).toBe('gpt4-target');
|
||||
expect(route('gpt-4-0613', { openai })).toBe('gpt4-target');
|
||||
expect(route('o1-preview', { openai })).toBe('gpt4-target');
|
||||
expect(route('O3-mini', { openai })).toBe('gpt4-target');
|
||||
});
|
||||
|
||||
it('does not treat gpt-4o, mini or turbo variants as the GPT-4 series', () => {
|
||||
const openai = { 'gpt-4-series': 'gpt4-target' };
|
||||
expect(route('gpt-4o', { openai })).toBe('gemini-3-flash');
|
||||
expect(route('gpt-4-turbo', { openai })).toBe('gemini-3-flash');
|
||||
});
|
||||
|
||||
it('maps the GPT-4o / 3.5 / mini / turbo series when configured', () => {
|
||||
const openai = { 'gpt-4o-series': 'mini-target' };
|
||||
expect(route('gpt-4o', { openai })).toBe('mini-target');
|
||||
expect(route('gpt-3.5-turbo', { openai })).toBe('mini-target');
|
||||
expect(route('gpt-4-turbo', { openai })).toBe('mini-target');
|
||||
expect(route('gpt-4o-mini', { openai })).toBe('mini-target');
|
||||
expect(route('some-mini-model', { openai })).toBe('mini-target');
|
||||
});
|
||||
|
||||
it('does not treat gemini names containing "mini" as the GPT-4o series', () => {
|
||||
const openai = { 'gpt-4o-series': 'mini-target' };
|
||||
expect(route('gemini-3-flash', { openai })).toBe('gemini-3-flash');
|
||||
});
|
||||
|
||||
it('maps GPT-5 to gpt-5-series, then falls back to gpt-4-series, then to the default', () => {
|
||||
expect(route('gpt-5.2', { openai: { 'gpt-5-series': 'g5', 'gpt-4-series': 'g4' } })).toBe(
|
||||
'g5',
|
||||
);
|
||||
expect(route('gpt-5', { openai: { 'gpt-4-series': 'g4' } })).toBe('g4');
|
||||
expect(route('gpt-5', { openai: { 'gpt-4o-series': 'mini' } })).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('falls through to the built-in table when the family key is not configured', () => {
|
||||
expect(route('gpt-4')).toBe('gemini-3-flash');
|
||||
expect(route('gpt-4', { openai: { 'gpt-4o-series': 'mini' } })).toBe('gemini-3-flash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anthropic families', () => {
|
||||
it('picks the 4.5, 3.5 or default family key from the model name', () => {
|
||||
const anthropic = {
|
||||
'claude-4.5-series': 'family-45',
|
||||
'claude-3.5-series': 'family-35',
|
||||
'claude-default': 'family-default',
|
||||
};
|
||||
expect(route('claude-sonnet-4-5-20250929', { anthropic })).toBe('family-45');
|
||||
expect(route('claude-sonnet-4.5', { anthropic })).toBe('family-45');
|
||||
expect(route('claude-3-5-sonnet-20241022', { anthropic })).toBe('family-35');
|
||||
expect(route('claude-3.5-haiku', { anthropic })).toBe('family-35');
|
||||
expect(route('claude-opus-4-6', { anthropic })).toBe('family-default');
|
||||
});
|
||||
|
||||
it('falls back to a legacy exact mapping when the family key is missing', () => {
|
||||
expect(route('claude-opus-4-6', { anthropic: { 'claude-opus-4-6': 'legacy-target' } })).toBe(
|
||||
'legacy-target',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the family key over the legacy exact mapping', () => {
|
||||
expect(
|
||||
route('claude-opus-4-6', {
|
||||
anthropic: { 'claude-default': 'family-default', 'claude-opus-4-6': 'legacy-target' },
|
||||
}),
|
||||
).toBe('family-default');
|
||||
});
|
||||
|
||||
it('is not applied to names that do not start with claude-', () => {
|
||||
expect(route('my-claude-model', { anthropic: { 'claude-default': 'family-default' } })).toBe(
|
||||
'my-claude-model',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls through to the built-in table when nothing is configured', () => {
|
||||
expect(route('claude-sonnet-4-5')).toBe('claude-sonnet-4-6-thinking');
|
||||
});
|
||||
});
|
||||
|
||||
describe('system default mapping', () => {
|
||||
it('maps known aliases and passes unknown names through', () => {
|
||||
expect(route('gemini-2.5-pro')).toBe('gemini-3.1-pro-high');
|
||||
expect(route('totally-unknown-model')).toBe('totally-unknown-model');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker';
|
||||
|
||||
function track(params: { status: number; body?: string; retryAfter?: string }) {
|
||||
return new RateLimitTracker().trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
backoffSteps: [],
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
function reasonFor(status: number, body?: string): RateLimitReason | undefined {
|
||||
return track({ status, body })?.reason;
|
||||
}
|
||||
|
||||
function googleBody(error: Record<string, unknown>): string {
|
||||
return JSON.stringify({ error });
|
||||
}
|
||||
|
||||
describe('RateLimitTracker.trackFromUpstreamError - reason detection', () => {
|
||||
it('ignores statuses that are not rate-limit related', () => {
|
||||
expect(track({ status: 200 })).toBeNull();
|
||||
expect(track({ status: 400 })).toBeNull();
|
||||
expect(track({ status: 401 })).toBeNull();
|
||||
});
|
||||
|
||||
describe('structured Google error details', () => {
|
||||
it.each([
|
||||
['QUOTA_EXHAUSTED', RateLimitReason.QuotaExhausted],
|
||||
['RATE_LIMIT_EXCEEDED', RateLimitReason.RateLimitExceeded],
|
||||
['MODEL_CAPACITY_EXHAUSTED', RateLimitReason.ModelCapacityExhausted],
|
||||
[' quota_exhausted ', RateLimitReason.QuotaExhausted],
|
||||
])('maps detail reason %s', (reason, expected) => {
|
||||
const body = googleBody({ details: [{ reason }] });
|
||||
expect(reasonFor(429, body)).toBe(expected);
|
||||
expect(reasonFor(503, body)).toBe(expected);
|
||||
});
|
||||
|
||||
it('skips blank, non-string and unknown reasons and uses the first mapped one', () => {
|
||||
const body = googleBody({
|
||||
details: [
|
||||
{ reason: ' ' },
|
||||
{ reason: 42 },
|
||||
{},
|
||||
{ reason: 'SOMETHING_ELSE' },
|
||||
{ reason: 'RATE_LIMIT_EXCEEDED' },
|
||||
{ reason: 'QUOTA_EXHAUSTED' },
|
||||
],
|
||||
});
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded);
|
||||
});
|
||||
|
||||
it('prefers detail reasons over the status field and the raw text', () => {
|
||||
const body = googleBody({
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
message: 'quota exceeded',
|
||||
details: [{ reason: 'RATE_LIMIT_EXCEEDED' }],
|
||||
});
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error status field', () => {
|
||||
it('treats RESOURCE_EXHAUSTED as quota exhaustion for any tracked status', () => {
|
||||
const body = googleBody({ status: ' resource_exhausted ' });
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.QuotaExhausted);
|
||||
expect(reasonFor(503, body)).toBe(RateLimitReason.QuotaExhausted);
|
||||
});
|
||||
|
||||
it('treats UNAVAILABLE with capacity wording as model capacity exhaustion', () => {
|
||||
expect(
|
||||
reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'No capacity available' })),
|
||||
).toBe(RateLimitReason.ModelCapacityExhausted);
|
||||
expect(
|
||||
reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'model capacity reached' })),
|
||||
).toBe(RateLimitReason.ModelCapacityExhausted);
|
||||
});
|
||||
|
||||
it('treats UNAVAILABLE without capacity wording as a server error (non-429)', () => {
|
||||
expect(reasonFor(503, googleBody({ status: 'UNAVAILABLE', message: 'try later' }))).toBe(
|
||||
RateLimitReason.ServerError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('status fallback', () => {
|
||||
it.each([404, 500, 503, 529])('classifies a plain %i as a server error', (status) => {
|
||||
expect(reasonFor(status, 'boom')).toBe(RateLimitReason.ServerError);
|
||||
expect(reasonFor(status, undefined)).toBe(RateLimitReason.ServerError);
|
||||
});
|
||||
|
||||
it('does not apply 429 text heuristics to other statuses', () => {
|
||||
expect(reasonFor(500, 'rate limit exceeded, quota exhausted')).toBe(
|
||||
RateLimitReason.ServerError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('429 text heuristics', () => {
|
||||
it.each(['Requests per minute exceeded', 'RATE LIMIT hit', 'rate_limit', 'Too Many Requests'])(
|
||||
'classifies %s as a rate limit',
|
||||
(body) => {
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.RateLimitExceeded);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['Model capacity exhausted', 'No capacity available right now'])(
|
||||
'classifies %s as model capacity',
|
||||
(body) => {
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.ModelCapacityExhausted);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['Quota reached', 'resources exhausted'])('classifies %s as quota', (body) => {
|
||||
expect(reasonFor(429, body)).toBe(RateLimitReason.QuotaExhausted);
|
||||
});
|
||||
|
||||
it('checks rate-limit wording before capacity and quota wording', () => {
|
||||
expect(reasonFor(429, 'too many requests: quota exhausted, no capacity available')).toBe(
|
||||
RateLimitReason.RateLimitExceeded,
|
||||
);
|
||||
expect(reasonFor(429, 'no capacity available, quota exhausted')).toBe(
|
||||
RateLimitReason.ModelCapacityExhausted,
|
||||
);
|
||||
});
|
||||
|
||||
it('is unknown when nothing matches or the body is missing', () => {
|
||||
expect(reasonFor(429, 'something odd')).toBe(RateLimitReason.Unknown);
|
||||
expect(reasonFor(429, undefined)).toBe(RateLimitReason.Unknown);
|
||||
expect(reasonFor(429, '{not json')).toBe(RateLimitReason.Unknown);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RateLimitTracker.trackFromUpstreamError - default retry delays', () => {
|
||||
const STEPS = [10, 20, 30];
|
||||
|
||||
it('uses configured backoff steps for quota exhaustion and clamps at the last one', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const delays = [1, 2, 3, 4].map(
|
||||
() =>
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
backoffSteps: STEPS,
|
||||
})?.retryAfterSec,
|
||||
);
|
||||
expect(delays).toEqual([10, 20, 30, 30]);
|
||||
});
|
||||
|
||||
it('falls back to built-in quota steps when none are configured', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const first = tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
backoffSteps: [],
|
||||
});
|
||||
expect(first?.retryAfterSec).toBe(60);
|
||||
});
|
||||
|
||||
it('uses a fixed 5 seconds for rate limits', () => {
|
||||
expect(track({ status: 429, body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
});
|
||||
|
||||
it('escalates model capacity delays 5, 10, 15 per account', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const delays = [1, 2, 3, 4].map(
|
||||
() =>
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'no capacity available',
|
||||
backoffSteps: [],
|
||||
})?.retryAfterSec,
|
||||
);
|
||||
expect(delays).toEqual([5, 10, 15, 15]);
|
||||
});
|
||||
|
||||
it('uses 5 seconds for 404 and 8 seconds for other server errors without escalating', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const first = tracker.trackFromUpstreamError({ accountId: 'a', status: 404, backoffSteps: [] });
|
||||
const second = tracker.trackFromUpstreamError({
|
||||
accountId: 'a',
|
||||
status: 500,
|
||||
backoffSteps: [],
|
||||
});
|
||||
const third = tracker.trackFromUpstreamError({ accountId: 'a', status: 500, backoffSteps: [] });
|
||||
expect([first?.retryAfterSec, second?.retryAfterSec, third?.retryAfterSec]).toEqual([5, 8, 8]);
|
||||
});
|
||||
|
||||
it('uses 60 seconds for unknown reasons', () => {
|
||||
expect(track({ status: 429, body: 'something odd' })?.retryAfterSec).toBe(60);
|
||||
});
|
||||
|
||||
it('prefers a numeric Retry-After header, with a 2 second floor and rounding up', () => {
|
||||
expect(track({ status: 429, retryAfter: '30' })?.retryAfterSec).toBe(30);
|
||||
expect(track({ status: 429, retryAfter: '1' })?.retryAfterSec).toBe(2);
|
||||
expect(track({ status: 429, retryAfter: '2.1' })?.retryAfterSec).toBe(3);
|
||||
});
|
||||
|
||||
it('parses an HTTP-date Retry-After header and ignores dates in the past', () => {
|
||||
const future = new Date(Date.now() + 45_000).toUTCString();
|
||||
const delay = track({ status: 429, retryAfter: future, body: 'rate limit' })?.retryAfterSec;
|
||||
expect(delay).toBeGreaterThanOrEqual(43);
|
||||
expect(delay).toBeLessThanOrEqual(46);
|
||||
|
||||
const past = new Date(Date.now() - 60_000).toUTCString();
|
||||
expect(track({ status: 429, retryAfter: past, body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
});
|
||||
|
||||
it('ignores zero, negative, blank and garbage Retry-After headers', () => {
|
||||
expect(track({ status: 429, retryAfter: '0', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
expect(track({ status: 429, retryAfter: '-5', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
expect(track({ status: 429, retryAfter: ' ', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
expect(track({ status: 429, retryAfter: 'soon', body: 'rate limit' })?.retryAfterSec).toBe(5);
|
||||
});
|
||||
|
||||
it('prefers the header over delays found in the body', () => {
|
||||
const body = googleBody({ details: [{ retryDelay: '99s' }] });
|
||||
expect(track({ status: 429, retryAfter: '7', body })?.retryAfterSec).toBe(7);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['retryDelay detail', googleBody({ details: [{ retryDelay: '12s' }] }), 12],
|
||||
[
|
||||
'metadata.retryDelay detail',
|
||||
googleBody({ details: [{ metadata: { retryDelay: '13s' } }] }),
|
||||
13,
|
||||
],
|
||||
[
|
||||
'metadata.quotaResetDelay detail',
|
||||
googleBody({ details: [{ metadata: { quotaResetDelay: '1m1s' } }] }),
|
||||
61,
|
||||
],
|
||||
['retry_after number', googleBody({ retry_after: 14.2 }), 15],
|
||||
['try again in minutes and seconds', 'Please try again in 2m 5s', 125],
|
||||
['try again in seconds', 'Please try again in 9s.', 9],
|
||||
['backoff for', 'backoff for 11s', 11],
|
||||
['quota reset message', 'Your quota will reset in 17 seconds', 17],
|
||||
])('uses the body hint (%s)', (_label, body, expected) => {
|
||||
expect(track({ status: 429, body })?.retryAfterSec).toBe(expected);
|
||||
});
|
||||
|
||||
it('skips unparseable body delays and falls through to the default', () => {
|
||||
const body = googleBody({ details: [{ retryDelay: 'later' }], retry_after: 0 });
|
||||
expect(track({ status: 429, body })?.retryAfterSec).toBe(60);
|
||||
});
|
||||
|
||||
it('does not escalate failure counters when a header or body hint is used', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
retryAfter: '30',
|
||||
backoffSteps: STEPS,
|
||||
});
|
||||
const next = tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
backoffSteps: STEPS,
|
||||
});
|
||||
expect(next?.retryAfterSec).toBe(10);
|
||||
});
|
||||
|
||||
it('locks quota exhaustion per model and other reasons per account', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'quota exhausted',
|
||||
model: 'm1',
|
||||
backoffSteps: [],
|
||||
});
|
||||
expect(tracker.isRateLimited('acc', 'm1')).toBe(true);
|
||||
expect(tracker.isRateLimited('acc', 'm2')).toBe(false);
|
||||
expect(tracker.isRateLimited('acc')).toBe(false);
|
||||
|
||||
tracker.trackFromUpstreamError({
|
||||
accountId: 'acc',
|
||||
status: 429,
|
||||
body: 'rate limit',
|
||||
model: 'm1',
|
||||
backoffSteps: [],
|
||||
});
|
||||
expect(tracker.isRateLimited('acc', 'm2')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker';
|
||||
|
||||
describe('RateLimitTracker parity replay', () => {
|
||||
it.each([
|
||||
['500ms', 2],
|
||||
['2500ms', 3],
|
||||
['2.2s100ms', 3],
|
||||
['1h2m3s', 3723],
|
||||
[' 30s ', 30],
|
||||
['30seconds', 5],
|
||||
['invalid30s', 5],
|
||||
])(
|
||||
'parses complete retry duration %s without confusing minutes and milliseconds',
|
||||
(duration, expected) => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const info = tracker.parseAndMarkFromError({
|
||||
accountId: 'acc-duration',
|
||||
status: 503,
|
||||
body: JSON.stringify({
|
||||
error: { details: [{ reason: 'MODEL_CAPACITY_EXHAUSTED', retryDelay: duration }] },
|
||||
}),
|
||||
backoffSteps: [60],
|
||||
});
|
||||
expect(info?.retryAfterSec).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('waits for the longer of the account and model lockouts', () => {
|
||||
const now = Date.parse('2026-01-01T00:00:00Z');
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
try {
|
||||
const tracker = new RateLimitTracker();
|
||||
tracker.setLockoutUntilIso(
|
||||
'acc-overlap',
|
||||
new Date(now + 5000).toISOString(),
|
||||
RateLimitReason.RateLimitExceeded,
|
||||
);
|
||||
tracker.setLockoutUntilIso(
|
||||
'acc-overlap',
|
||||
new Date(now + 60000).toISOString(),
|
||||
RateLimitReason.QuotaExhausted,
|
||||
'model-a',
|
||||
);
|
||||
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-a')).toBe(60);
|
||||
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-b')).toBe(5);
|
||||
clock.mockReturnValue(now + 10000);
|
||||
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-a')).toBe(50);
|
||||
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-b')).toBe(0);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses Retry-After header before body/default', () => {
|
||||
const tracker = new RateLimitTracker();
|
||||
const info = tracker.parseAndMarkFromError({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
apiKeyMatches,
|
||||
hasConfiguredApiKey,
|
||||
extractApiKeyToken,
|
||||
type RequestHeaders,
|
||||
@@ -81,4 +82,19 @@ describe('api-key-auth.util', () => {
|
||||
expect(extractApiKeyToken(headers)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiKeyMatches', () => {
|
||||
it('accepts only the exact configured key', () => {
|
||||
expect(apiKeyMatches('secret-key', 'secret-key')).toBe(true);
|
||||
expect(apiKeyMatches('secret-key ', 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('Secret-Key', 'secret-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing, empty and different-length tokens without throwing', () => {
|
||||
expect(apiKeyMatches(null, 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('', 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('secret', 'secret-key')).toBe(false);
|
||||
expect(apiKeyMatches('secret-key-and-more', 'secret-key')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TtlCache } from '../../utils/ttl-cache';
|
||||
|
||||
describe('TtlCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns stored values until the ttl elapses', () => {
|
||||
const cache = new TtlCache<string>(1000, 10);
|
||||
cache.set('a', 'value');
|
||||
expect(cache.get('a')).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(cache.get('a')).toBe('value');
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(cache.get('a')).toBeUndefined();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown keys', () => {
|
||||
expect(new TtlCache<string>(1000, 10).get('missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops expired entries on write even if they are never read again', () => {
|
||||
const cache = new TtlCache<number>(1000, 10);
|
||||
cache.set('old-1', 1);
|
||||
cache.set('old-2', 2);
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
cache.set('fresh', 3);
|
||||
expect(cache.size).toBe(1);
|
||||
expect(cache.get('fresh')).toBe(3);
|
||||
});
|
||||
|
||||
it('evicts the oldest entries when the limit is exceeded', () => {
|
||||
const cache = new TtlCache<number>(60_000, 2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
cache.set('c', 3);
|
||||
|
||||
expect(cache.size).toBe(2);
|
||||
expect(cache.get('a')).toBeUndefined();
|
||||
expect(cache.get('b')).toBe(2);
|
||||
expect(cache.get('c')).toBe(3);
|
||||
});
|
||||
|
||||
it('refreshes ttl and recency when a key is overwritten', () => {
|
||||
const cache = new TtlCache<number>(1000, 2);
|
||||
cache.set('a', 1);
|
||||
cache.set('b', 2);
|
||||
vi.advanceTimersByTime(600);
|
||||
cache.set('a', 10);
|
||||
cache.set('c', 3);
|
||||
|
||||
expect(cache.get('b')).toBeUndefined();
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(cache.get('a')).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CloudAccount } from '../types/cloudAccount';
|
||||
|
||||
/**
|
||||
* Returns a copy of the account that is safe to hand to the renderer.
|
||||
*
|
||||
* The renderer never needs the OAuth credentials: they are only used by the main process
|
||||
* (quota refresh, IDE switch, proxy). Keeping them out of IPC payloads means an XSS or a
|
||||
* compromised renderer cannot read refresh tokens. The non-secret token metadata
|
||||
* (expiry, project id, tier flags) is preserved so the output schema stays valid.
|
||||
*/
|
||||
export function redactCloudAccountTokens<T extends CloudAccount>(account: T): T {
|
||||
return {
|
||||
...account,
|
||||
token: {
|
||||
...account.token,
|
||||
access_token: '',
|
||||
refresh_token: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Small in-memory cache with a fixed time-to-live and a hard entry limit.
|
||||
*
|
||||
* Entries live in insertion order and share one TTL, so expiry order matches insertion order:
|
||||
* pruning can stop at the first live entry. The size cap evicts the oldest entries, which keeps
|
||||
* memory bounded even when callers never read a key again (a plain Map only drops expired
|
||||
* entries on read).
|
||||
*/
|
||||
export class TtlCache<V> {
|
||||
private readonly entries = new Map<string, { value: V; expiresAt: number }>();
|
||||
|
||||
constructor(
|
||||
private readonly ttlMs: number,
|
||||
private readonly maxEntries: number,
|
||||
) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
this.entries.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V): void {
|
||||
const now = Date.now();
|
||||
// Re-inserting moves the key to the newest position so insertion order stays expiry order.
|
||||
this.entries.delete(key);
|
||||
this.pruneExpired(now);
|
||||
this.entries.set(key, { value, expiresAt: now + this.ttlMs });
|
||||
this.evictOverflow();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
private pruneExpired(now: number): void {
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (entry.expiresAt > now) {
|
||||
return;
|
||||
}
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private evictOverflow(): void {
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const oldestKey = this.entries.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
return;
|
||||
}
|
||||
this.entries.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user