diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000000000000000000000000000000000000..508337151835185d5dafcb6b5978d12e335a24b9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +node_modules +npm-debug.log* +.env +.env.* +data +*.log +tests +coverage +README.hf.md +IMPLEMENTATION_GUIDE.md diff --git a/.gitattributes b/.gitattributes new file mode 100755 index 0000000000000000000000000000000000000000..b8041e17b95877bff4535d105ff784c825672913 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Mark generated CSS file to reduce diff noise +public/css/style.css linguist-generated=true -diff diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100755 index 0000000000000000000000000000000000000000..e7bc97cf50a7d6d48c5406c34972331b26b481f8 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: badrinarayanans diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100755 index 0000000000000000000000000000000000000000..077d9d30cd0d83f79614ffb92e330ce7c526d645 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,53 @@ +--- +name: Bug Report +about: Report a bug or issue with the proxy +title: '[BUG] ' +labels: bug +assignees: '' +--- + +**Bug Description** +A clear and concise description of the bug. + +**Steps to Reproduce** +1. +2. +3. + +**Expected Behavior** +What should happen. + +**Actual Behavior** +What actually happens. + +**Environment** +- Claude Code version: +- antigravity-claude-proxy version: +- Operating System: +- Node.js version: + +**Claude CLI Configuration** +Please provide ONE of the following: +- Screenshot of WebUI **Settings → Claude CLI** tab, OR +- Contents of your Claude settings.json file: + - macOS/Linux: `~/.claude/settings.json` + - Windows: `%USERPROFILE%\.claude\settings.json` + +```json + +``` + +**Logs (if applicable)** + + +**Checklist** +Please confirm: +- [ ] I've updated antigravity-claude-proxy to the latest version +- [ ] The bug exists in the latest version +- [ ] I'm using this proxy for personal development only +- [ ] I have an active Google Cloud project with Antigravity enabled +- [ ] This issue is not related to attempting commercial use or TOS violations +- [ ] I've reviewed the README troubleshooting section + +**Additional Context** +Add any other relevant information. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100755 index 0000000000000000000000000000000000000000..966dc16a62c89bcbded0a42af972afba98cce14d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Troubleshooting Guide + url: https://github.com/badrisnarayanan/antigravity-claude-proxy/blob/main/docs/troubleshooting.md + about: Check here first for common issues and solutions before creating a bug report + - name: Google Cloud Support + url: https://cloud.google.com/support + about: For Google Cloud account or billing issues, contact Google Cloud directly + - name: Google Terms of Service + url: https://policies.google.com/terms + about: Review Google's Terms of Service for compliance questions + - name: Discussions + url: https://github.com/badrisnarayanan/antigravity-claude-proxy/discussions + about: Ask questions or discuss the proxy with the community diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100755 index 0000000000000000000000000000000000000000..ccd976fd693e598c592aa4d9b325a9a5c9b55c3d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,28 @@ +--- +name: Feature Request +about: Suggest a new feature or enhancement +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +**Feature Description** +A clear description of the feature you'd like to see. + +**Use Case** +Explain how this feature would be used and what problem it solves. + +**Proposed Implementation** +If you have ideas about how this could be implemented, share them here. + +**Compliance Consideration** +Please confirm: +- [ ] This feature is for personal development use +- [ ] This feature does not violate or circumvent Google's Terms of Service +- [ ] This feature is not intended for commercial resale or multi-user access + +**Alternatives Considered** +Have you considered any alternative solutions or workarounds? + +**Additional Context** +Add any other context, screenshots, or examples. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100755 index 0000000000000000000000000000000000000000..5867c8597032567f4f49f06f2a3353d8dc7f5e54 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,29 @@ +name: Publish to npm + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Update version from release tag + run: npm version ${{ github.event.release.tag_name }} --no-git-tag-version --allow-same-version + + - name: Publish to npm + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000000000000000000000000000000000000..22a627ee47ee05a5add7dfb848b695cc8a984bbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log +log.txt + +# IDE +.vscode/ +.idea/ +.history/ + +# Local config (may contain tokens) +.claude/ +.deepvcode/ + +# Runtime data +data/ + +# Test artifacts +tests/utils/*.png +tests/utils/*.jpg +tests/utils/*.jpeg + +# Environment +.env +.env.local +hf-config-env.txt + +# Build output +dist/ +build/ diff --git a/.npmignore b/.npmignore new file mode 100755 index 0000000000000000000000000000000000000000..7b7da165b7b3e190ab9ce3a3048fcbe191b5067d --- /dev/null +++ b/.npmignore @@ -0,0 +1,21 @@ +# Exclude tests +tests/ + +# Exclude GitHub workflows +.github/ + +# Exclude documentation (except README) +CLAUDE.md +CHANGELOG.md +CONTRIBUTING.md +docs/ + +# Exclude dev files +.env* +.claude/ +.vscode/ +.idea/ + +# Exclude misc +*.log +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100755 index 0000000000000000000000000000000000000000..82367ce85c256c85c4be1c97c53cb902fabcd7cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md + +Node.js proxy that exposes an Anthropic-compatible API backed by Google's Cloud Code service, letting Claude Code CLI use Gemini (and Claude) models via Google accounts with multi-account quota management. + +Request flow: `Claude Code CLI → Express (server.js) → CloudCode client → Antigravity Cloud Code API` + +## Commands + +```bash +npm install # installs deps and builds CSS (prepare hook) +npm start # port 8080 +npm run dev # watch server files +npm run dev:full # watch CSS + server files + +npm start -- --strategy=sticky # cache-optimized (default is hybrid) +npm start -- --strategy=round-robin # load-balanced +npm start -- --fallback # fall back to alternate model on quota exhaustion +npm start -- --dev-mode # enables debug logging + dev tools (--debug is a legacy alias) + +npm run build:css # compile Tailwind once +npm run watch:css # watch CSS + +npm run accounts:add # add Google account via OAuth +npm run accounts:add -- --no-browser # headless/manual code input +npm run accounts:list +npm run accounts:verify + +npm test # requires server running on port 8080 +node tests/run-all.cjs # run matching tests only +node tests/test-strategies.cjs # strategy unit tests (no server needed) +``` + +## Non-obvious things + +**CSS**: Source is `public/css/src/input.css` (Tailwind + `@apply`). Compiled output is `public/css/style.css` — don't edit the compiled file. + +**Quota thresholds** are stored as fractions (0–0.99) but displayed as percentages in the UI. Three-tier resolution: per-model > per-account > global. + +**`cache_control` stripping**: Claude Code CLI sends `cache_control` on content blocks; Cloud Code API rejects them. Stripped at the start of `convertAnthropicToGoogle()` before any other processing. + +**Cross-model thinking signatures**: Claude and Gemini signatures are incompatible. When switching models mid-conversation, mismatched signatures are dropped. Gemini targets: strict (drop unknown). Claude targets: lenient (let Claude validate). + +**`CLAUDE_CONFIG_PATH` env var**: Set this when running as a systemd service — `os.homedir()` returns the service user's home, not the real user's. + +**`WEBUI_PASSWORD` env var**: Enables password protection on the web UI. + +**Native module rebuild**: On Node.js version mismatch, `better-sqlite3` is auto-rebuilt via `npm rebuild`. If reload still fails after rebuild, a server restart is required. + +**Dev mode sub-toggles** are client-side only (localStorage in `settings-store.js`): screenshot/redact mode, debug logging, log export, health inspector, placeholder data. No backend involvement. + +**`/api/strategy/health`** returns 403 unless dev mode is on. diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000000000000000000000000000000000000..3f268001f52afcc208e703fb9173c3650f6fcd23 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +FROM node:20-bookworm-slim AS build + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +COPY . . +RUN npm ci +RUN npm run build:css +RUN npm prune --omit=dev + +FROM node:20-bookworm-slim AS runtime + +WORKDIR /app + +ENV NODE_ENV=production \ + PORT=7860 \ + HOST=0.0.0.0 \ + HOME=/home/node \ + ACCOUNT_CONFIG_PATH=/data/accounts.json + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /data /home/node/.config/antigravity-proxy \ + && chown -R node:node /data /home/node /app + +COPY --from=build --chown=node:node /app/package.json ./package.json +COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/src ./src +COPY --from=build --chown=node:node /app/bin ./bin +COPY --from=build --chown=node:node /app/public ./public +COPY --from=build --chown=node:node /app/config.example.json ./config.example.json + +USER node + +EXPOSE 7860 + +ENTRYPOINT ["tini", "--"] +CMD ["node", "src/index.js"] diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000000000000000000000000000000000000..3bea5c2645fb9aa725615754b168bc645ebc5d50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Badri Narayanan S + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100755 index 0000000000000000000000000000000000000000..debc5e4f96a248d44a67ab212dc24a92cfff0241 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +--- +title: Antigravity OpenAI Proxy +emoji: 🛸 +colorFrom: indigo +colorTo: blue +sdk: docker +app_port: 7860 +pinned: false +--- + +# Antigravity OpenAI Proxy + +A test-only Hugging Face Space that exposes both: + +- `POST /v1/chat/completions` (OpenAI-compatible) +- `POST /v1/messages` (Anthropic-compatible) + +The proxy rotates across Antigravity OAuth accounts using the existing hybrid account manager. + +## Required Space secrets + +- `PROXY_API_KEY`: bearer token required by `/v1/*` +- `CONFIG_ENV`: newline-delimited placeholder-compatible account variables such as `ANTIGRAVITY_1_EMAIL` and `ANTIGRAVITY_1_REFRESH_TOKEN` + +Optional variables: + +- `FORCE_ENV_ACCOUNTS=true`: make environment accounts authoritative at each cold start +- `ENABLE_WEBUI=false`: disable the browser UI +- `ACCOUNT_STRATEGY=hybrid`: account selection strategy + +Never commit OAuth refresh tokens or a real `.env` file. + +## Health check + +```bash +curl -fsS https://YOUR-SPACE.hf.space/health +``` + +## OpenAI-compatible request + +```bash +curl -sS https://YOUR-SPACE.hf.space/v1/chat/completions \ + -H "Authorization: Bearer YOUR_PROXY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "antigravity/gemini-3-flash", + "messages": [{"role": "user", "content": "Reply with READY only."}], + "reasoning_effort": "high" + }' +``` + +This Space is for testing only. Antigravity access may be subject to Google terms, quotas, and account enforcement. diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 0000000000000000000000000000000000000000..5d3434a20a96247a4994cdd6c6a28c8d6296b6ee --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,490 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'fs'; +import { spawn, exec } from 'child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Read package.json for version +const packageJson = JSON.parse( + readFileSync(join(__dirname, '..', 'package.json'), 'utf-8') +); + +// PID file location for background process management +const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'antigravity-proxy'); +const PID_FILE = join(CONFIG_DIR, 'server.pid'); + +const args = process.argv.slice(2); +const command = args[0]; + +// Ensure config directory exists +function ensureConfigDir() { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +/** + * Check if the service is running by reading PID file and verifying process + */ +function isServiceRunning() { + if (!existsSync(PID_FILE)) { + return false; + } + + try { + const pidStr = readFileSync(PID_FILE, 'utf-8'); + const pid = parseInt(pidStr, 10); + if (isNaN(pid)) { + cleanupPidFile(); + return false; + } + + // Check if process is running (signal 0 doesn't kill, just checks) + process.kill(pid, 0); + return true; + } catch (e) { + // Process doesn't exist + cleanupPidFile(); + return false; + } +} + +/** + * Get the PID of the running service + */ +function getServicePid() { + if (!existsSync(PID_FILE)) { + return null; + } + try { + const pid = parseInt(readFileSync(PID_FILE, 'utf-8'), 10); + return isNaN(pid) ? null : pid; + } catch (e) { + return null; + } +} + +/** + * Save PID to file + */ +function savePid(pid) { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(PID_FILE, pid.toString()); +} + +/** + * Clean up PID file + */ +function cleanupPidFile() { + if (existsSync(PID_FILE)) { + try { + unlinkSync(PID_FILE); + } catch (e) { + // Ignore cleanup errors + } + } +} + +/** + * Get current port from environment or default + */ +function getPort() { + return process.env.PORT || 8080; +} + +/** + * Wait for service to be ready + */ +async function waitForService(timeout = 10000, initialDelay = 1000) { + await new Promise(resolve => setTimeout(resolve, initialDelay)); + + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (isServiceRunning()) { + // Additional wait to ensure service is fully ready + await new Promise(resolve => setTimeout(resolve, 500)); + return true; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; +} + +/** + * Start the server as a background process (or foreground with --log) + */ +async function startServer() { + // Check for --log flag + const logMode = args.includes('--log'); + + if (isServiceRunning() && !logMode) { + console.log(''); + console.log('╭' + '─'.repeat(48) + '╮'); + console.log('│ 🛸 Antigravity Proxy is already in orbit │'); + console.log('╰' + '─'.repeat(48) + '╯'); + console.log(''); + + const pid = getServicePid(); + const port = getPort(); + console.log(` ┌─ PID: ${pid}`); + console.log(` ├─ Local: http://localhost:${port}`); + console.log(` └─ Dashboard: http://localhost:${port}`); + console.log(''); + return; + } + + console.log(''); + if (logMode) { + console.log('🌌 Launching Antigravity Proxy (foreground mode)...'); + console.log(' Press Ctrl+C to stop'); + console.log(''); + } else { + console.log('🌌 Launching Antigravity Proxy...'); + } + + const serverScript = join(__dirname, '..', 'src', 'index.js'); + const port = getPort(); + + // Filter out --log from args passed to server + const serverArgs = args.slice(1).filter(arg => arg !== '--log'); + + if (logMode) { + // Foreground mode - show logs directly + const serverProcess = spawn('node', [serverScript, ...serverArgs], { + stdio: 'inherit', // Show output in current terminal + env: { ...process.env, PORT: port.toString() } + }); + + serverProcess.on('error', (error) => { + console.error(''); + console.error('⚠️ Launch failed:', error.message); + console.error(''); + process.exit(1); + }); + + serverProcess.on('exit', (code) => { + console.log(''); + console.log('🌙 Proxy has exited'); + console.log(''); + process.exit(code || 0); + }); + + // Keep process running + return; + } + + // Background mode - detached process + const serverProcess = spawn('node', [serverScript, ...serverArgs], { + detached: true, + stdio: 'ignore', + env: { ...process.env, PORT: port.toString() } + }); + + serverProcess.on('error', (error) => { + console.error(''); + console.error('⚠️ Launch failed:', error.message); + console.error(''); + process.exit(1); + }); + + // Save PID and detach + savePid(serverProcess.pid); + serverProcess.unref(); + + // Wait for service to be ready + if (await waitForService()) { + console.log('╭' + '─'.repeat(48) + '╮'); + console.log('│ ⚡ Proxy is now in orbit! │'); + console.log('╰' + '─'.repeat(48) + '╯'); + + console.log(''); + console.log(' ┌─ Process ID:', serverProcess.pid); + console.log(' ├─ Local:', `http://localhost:${port}`); + console.log(' └─ Dashboard:', `http://localhost:${port}/`); + console.log(''); + console.log(' Next steps:'); + console.log(' • acc ui → Open dashboard'); + console.log(' • acc status → View proxy health'); + console.log(' • acc stop → Shut down proxy'); + console.log(''); + } else { + console.error(''); + console.error('⚠️ Proxy launched but health check timed out'); + console.log(` Try: curl http://localhost:${port}/health`); + console.error(''); + } +} + +/** + * Stop the running server + */ +function stopServer() { + if (!isServiceRunning()) { + console.log(''); + console.log('🌑 Proxy is not running'); + console.log(''); + cleanupPidFile(); + return; + } + + const pid = getServicePid(); + try { + process.kill(pid, 'SIGTERM'); + cleanupPidFile(); + console.log(''); + console.log('🌙 Proxy has been taken offline'); + console.log(''); + } catch (e) { + console.error(''); + console.error('⚠️ Shutdown failed:', e.message); + console.error(''); + cleanupPidFile(); + } +} + +/** + * Restart the server + */ +async function restartServer() { + console.log(''); + console.log('♻️ Restarting proxy...'); + console.log(''); + + // Stop if running + if (isServiceRunning()) { + const pid = getServicePid(); + try { + process.kill(pid, 'SIGTERM'); + cleanupPidFile(); + console.log(' └─ Existing instance stopped'); + console.log(''); + // Wait for process to fully terminate + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (e) { + console.log(' └─ No previous instance found'); + console.log(''); + cleanupPidFile(); + } + } + + // Start fresh + await startServer(); +} + +/** + * Show server status + */ +function showStatus() { + console.log(''); + console.log('╭' + '─'.repeat(48) + '╮'); + console.log('│ 🛸 Antigravity Claude Proxy │'); + console.log('╰' + '─'.repeat(48) + '╯'); + console.log(''); + + if (isServiceRunning()) { + const pid = getServicePid(); + const port = getPort(); + console.log(' STATUS'); + console.log(' ⚡ Proxy is active'); + console.log(''); + console.log(' DETAILS'); + console.log(` ├─ PID: ${pid}`); + console.log(` ├─ Port: ${port}`); + console.log(` ├─ API: http://localhost:${port}`); + console.log(` └─ Dashboard: http://localhost:${port}/`); + console.log(''); + console.log(' AVAILABLE COMMANDS'); + console.log(' • acc ui Open dashboard'); + console.log(' • acc restart Relaunch proxy'); + console.log(' • acc stop Take offline'); + } else { + console.log(' STATUS'); + console.log(' 🌑 Proxy is offline'); + console.log(''); + console.log(' TO LAUNCH'); + console.log(' • acc start Bring proxy online'); + } + console.log(''); +} + +/** + * Open WebUI in browser + */ +async function openUI() { + // Start server if not running + if (!isServiceRunning()) { + console.log(''); + console.log('🌌 Proxy offline - launching now...'); + await startServer(); + // Wait for it to be fully ready + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + const port = getPort(); + const uiUrl = `http://localhost:${port}/`; + + console.log(''); + console.log(`🖥️ Opening dashboard → ${uiUrl}`); + console.log(''); + + // Open URL in browser based on platform + const platform = process.platform; + let openCommand = ''; + + if (platform === 'win32') { + openCommand = `start ${uiUrl}`; + } else if (platform === 'darwin') { + openCommand = `open ${uiUrl}`; + } else if (platform === 'linux') { + openCommand = `xdg-open ${uiUrl}`; + } else { + console.error('⚠️ Cannot auto-open browser on this platform'); + console.log(` Manual URL: ${uiUrl}`); + console.log(''); + return; + } + + exec(openCommand, (error) => { + if (error) { + console.error('⚠️ Browser launch failed:', error.message); + console.log(` Manual URL: ${uiUrl}`); + console.log(''); + } + }); +} + +function showHelp() { + console.log(` +╭${'─'.repeat(58)}╮ +│ 🛸 Antigravity Claude Proxy v${packageJson.version.padEnd(27)}│ +╰${'─'.repeat(58)}╯ + +Route Claude Code CLI through Antigravity's multi-model API +with intelligent load balancing across Google accounts. + +USAGE + antigravity-claude-proxy [options] + acc [options] ← shorthand + +━━━ PROXY CONTROL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + start Launch proxy as background service + stop Shut down the proxy + restart Relaunch the proxy + status View proxy health and details + ui Open dashboard in browser + +━━━ ACCOUNT MANAGEMENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + accounts Interactive account menu + accounts add Add Google account via OAuth + accounts list Show all linked accounts + accounts remove Unlink accounts + accounts verify Check account health + accounts clear Remove all accounts + +━━━ OPTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + --help, -h Show this help + --version, -v Show version + --log Run in foreground with visible logs + --strategy=NAME Load balancing: hybrid (default), + sticky (cache-optimized), round-robin + --fallback Enable model fallback on errors + +━━━ ENVIRONMENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + PORT Custom port (default: 8080) + +━━━ EXAMPLES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + acc start Launch proxy + acc start --log Launch with visible logs + acc ui Open dashboard + PORT=3000 acc start Use custom port + acc start --strategy=sticky Optimize for prompt caching + acc accounts add Link new Google account + +━━━ CLAUDE CODE SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Edit ~/.claude/settings.json: + { + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:8080" + } + } + +Docs: https://github.com/badrisnarayanan/antigravity-claude-proxy +`); +} + +function showVersion() { + console.log(packageJson.version); +} + +async function main() { + // Handle flags + if (args.includes('--help') || args.includes('-h')) { + showHelp(); + process.exit(0); + } + + if (args.includes('--version') || args.includes('-v')) { + showVersion(); + process.exit(0); + } + + // Handle commands + switch (command) { + case 'start': + await startServer(); + break; + + case 'stop': + stopServer(); + break; + + case 'restart': + await restartServer(); + break; + + case 'status': + showStatus(); + break; + + case 'ui': + await openUI(); + break; + + case 'accounts': { + // Pass remaining args to accounts CLI + const subCommand = args[1] || 'add'; + process.argv = ['node', 'accounts-cli.js', subCommand, ...args.slice(2)]; + await import('../src/cli/accounts.js'); + break; + } + + case 'help': + showHelp(); + break; + + case 'version': + showVersion(); + break; + + case undefined: + // No command - show help + showHelp(); + break; + + default: + console.error(`Unknown command: ${command}`); + console.error('Run "acc --help" for usage information.'); + process.exit(1); + } +} + +main().catch((err) => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/config.example.json b/config.example.json new file mode 100755 index 0000000000000000000000000000000000000000..17c5d67f060ea185f7842efbc16e3b9db2232e4d --- /dev/null +++ b/config.example.json @@ -0,0 +1,60 @@ +{ + "_comment": "Antigravity Claude Proxy Configuration", + "_instructions": [ + "HOW TO USE THIS FILE:", + "1. Copy to your HOME directory: ~/.config/antigravity-proxy/config.json", + " - Windows: C:\\Users\\\\.config\\antigravity-proxy\\config.json", + " - macOS/Linux: ~/.config/antigravity-proxy/config.json", + "2. Or copy to project root as 'config.json' (fallback if home config not found)", + "", + "NOTE: Environment variables (e.g., WEBUI_PASSWORD) take precedence over file config", + "Restart server after making changes" + ], + + "apiKey": "", + "_apiKey_comment": "Optional API key to protect /v1/* endpoints. Can also use API_KEY env var.", + + "webuiPassword": "", + "_webuiPassword_comment": "Optional password to protect WebUI. Can also use WEBUI_PASSWORD env var.", + + "port": 8080, + "debug": false, + "logLevel": "info", + + "maxRetries": 5, + "retryBaseMs": 1000, + "retryMaxMs": 30000, + + "defaultCooldownMs": 60000, + "maxWaitBeforeErrorMs": 120000, + + "tokenCacheTtlMs": 300000, + "persistTokenCache": false, + + "requestTimeoutMs": 300000, + + "maxAccounts": 10, + "_maxAccounts_comment": "Maximum number of Google accounts allowed (1-100). Default: 10.", + + "_oauthCallbackPort_comment": "OAuth callback server port. Change if you get EACCES errors on Windows. Can also use OAUTH_CALLBACK_PORT env var. Default: 51121.", + "_oauthCallbackPort_env": "OAUTH_CALLBACK_PORT=3456", + + "_profiles": { + "development": { + "debug": true, + "logLevel": "debug", + "maxRetries": 3 + }, + "production": { + "debug": false, + "logLevel": "info", + "maxRetries": 5, + "persistTokenCache": true + }, + "high-performance": { + "maxRetries": 10, + "retryMaxMs": 60000, + "tokenCacheTtlMs": 600000 + } + } +} diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md new file mode 100755 index 0000000000000000000000000000000000000000..546a39347fba2dbd03649cba97372b4d8d785951 --- /dev/null +++ b/docs/api-endpoints.md @@ -0,0 +1,9 @@ +# API Endpoints + +| Endpoint | Method | Description | +| ----------------- | ------ | --------------------------------------------------------------------- | +| `/health` | GET | Health check | +| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) | +| `/v1/messages` | POST | Anthropic Messages API | +| `/v1/models` | GET | List available models | +| `/refresh-token` | POST | Force token refresh | diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100755 index 0000000000000000000000000000000000000000..f94131b99b33a804c5ca72d58b8082c585106182 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,94 @@ +# Advanced Configuration + +While most users can use the default settings, you can tune the proxy behavior via the **Settings → Server** tab in the WebUI or by creating a `config.json` file. + +## Environment Variables + +The proxy supports the following environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | Server port | `8080` | +| `HOST` | Bind address | `0.0.0.0` | +| `HTTP_PROXY` | Route outbound requests through a proxy | - | +| `HTTPS_PROXY` | Same as HTTP_PROXY (for HTTPS requests) | - | +| `API_KEY` | Protect `/v1/*` API endpoints | - | +| `WEBUI_PASSWORD` | Password-protect the web dashboard | - | +| `DEBUG` | Enable debug logging (`true`/`false`) | `false` | +| `DEV_MODE` | Enable developer mode (`true`/`false`) | `false` | +| `FALLBACK` | Enable model fallback (`true`/`false`) | `false` | +| `FALLBACK_ANTIGRAVITY_VERSION` | Override the User-Agent version string (useful when Google APIs reject old versions) | `1.23.2` | +| `ANTIGRAVITY_CLIENT_VERSION` | Override the X-Client-Version header sent to the API (prevents "no longer supported" errors) | Auto-detected from product.json | +| `ANTIGRAVITY_CLIENT_VERSION_FALLBACK` | Override the fallback X-Client-Version (used when product.json is not found) | `1.110.0` | +| `CLAUDE_CONFIG_PATH` | Path to `.claude` directory for CLI settings (for systemd/service deployments) | `~/.claude` | + +### Setting Environment Variables + +#### Inline (single command) + +Set variables for just one command. Works on macOS, Linux, and Windows with Git Bash/WSL: + +```bash +PORT=3000 HTTP_PROXY=http://proxy:8080 npm start +``` + +#### macOS / Linux (persistent) + +Add to your shell profile (`~/.zshrc` or `~/.bashrc`): + +```bash +export PORT=3000 +export HTTP_PROXY=http://proxy:8080 +``` + +Then reload: `source ~/.zshrc` + +#### Windows Command Prompt (persistent) + +```cmd +setx PORT 3000 +setx HTTP_PROXY http://proxy:8080 +``` + +Restart your terminal for changes to take effect. + +#### Windows PowerShell (persistent) + +```powershell +[Environment]::SetEnvironmentVariable("PORT", "3000", "User") +[Environment]::SetEnvironmentVariable("HTTP_PROXY", "http://proxy:8080", "User") +``` + +Restart your terminal for changes to take effect. + +### HTTP Proxy Support + +If you're behind a corporate firewall or VPN, you can route all outbound API requests through a proxy server: + +```bash +# Route through a local proxy (e.g., for debugging with mitmproxy) +HTTP_PROXY=http://127.0.0.1:8888 npm start + +# Route through a corporate proxy +HTTP_PROXY=http://proxy.company.com:3128 npm start + +# With authentication +HTTP_PROXY=http://user:password@proxy.company.com:3128 npm start +``` + +The proxy supports `http_proxy`, `HTTP_PROXY`, `https_proxy`, and `HTTPS_PROXY` (case-insensitive). + +## Configurable Options + +- **API Key Authentication**: Protect `/v1/*` API endpoints with `API_KEY` env var or `apiKey` in config. +- **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config. +- **Custom Port**: Change the default `8080` port. +- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`. +- **Rate Limit Handling**: Comprehensive rate limit detection from headers and error messages with intelligent retry-after parsing. +- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`. +- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts. +- **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10. +- **Quota Threshold**: Set `globalQuotaThreshold` (0-0.99) to switch accounts before quota drops below a minimum level. Supports per-account and per-model overrides. +- **Endpoint Fallback**: Automatic 403/404 endpoint fallback for API compatibility. + +Refer to `config.example.json` for a complete list of fields and documentation. diff --git a/docs/development.md b/docs/development.md new file mode 100755 index 0000000000000000000000000000000000000000..9d60d2f5252187f33dc153aec6a3fe2df5b1ff46 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,54 @@ +# Development + +## For Developers & Contributors + +This project uses a local Tailwind CSS build system. CSS is pre-compiled and included in the repository, so you can run the project immediately after cloning. + +### Quick Start + +```bash +git clone https://github.com/badri-s2001/antigravity-claude-proxy.git +cd antigravity-claude-proxy +npm install # Automatically builds CSS via prepare hook +npm start # Start server (no rebuild needed) +``` + +### Frontend Development + +If you need to modify styles in `public/css/src/input.css`: + +```bash +# Option 1: Build once +npm run build:css + +# Option 2: Watch for changes (auto-rebuild) +npm run watch:css + +# Option 3: Watch both CSS and server (recommended) +npm run dev:full +``` + +**File Structure:** +- `public/css/src/input.css` - Source CSS with Tailwind `@apply` directives (edit this) +- `public/css/style.css` - Compiled & minified CSS (auto-generated, don't edit) +- `tailwind.config.js` - Tailwind configuration +- `postcss.config.js` - PostCSS configuration + +### Backend-Only Development + +If you're only working on backend code and don't need frontend dev tools: + +```bash +npm install --production # Skip devDependencies (saves ~20MB) +npm start +``` + +**Note:** Pre-compiled CSS is committed to the repository, so you don't need to rebuild unless modifying styles. + +### Project Structure + +See [CLAUDE.md](../CLAUDE.md) for detailed architecture documentation, including: +- Request flow and module organization +- Frontend architecture (Alpine.js + Tailwind) +- Service layer patterns (`ErrorHandler.withLoading`, `AccountActions`) +- Dashboard module documentation diff --git a/docs/legal.md b/docs/legal.md new file mode 100755 index 0000000000000000000000000000000000000000..806fb2f17b72b493b0c66042c48dedc3d1343e63 --- /dev/null +++ b/docs/legal.md @@ -0,0 +1,9 @@ +# Legal + +- **Not affiliated with Google or Anthropic.** This is an independent open-source project and is not endorsed by, sponsored by, or affiliated with Google LLC or Anthropic PBC. + +- "Antigravity", "Gemini", "Google Cloud", and "Google" are trademarks of Google LLC. + +- "Claude" and "Anthropic" are trademarks of Anthropic PBC. + +- Software is provided "as is", without warranty. You are responsible for complying with all applicable Terms of Service and Acceptable Use Policies. diff --git a/docs/load-balancing.md b/docs/load-balancing.md new file mode 100755 index 0000000000000000000000000000000000000000..f958022df7e39887b27b56efa7111ed6e61a6dc1 --- /dev/null +++ b/docs/load-balancing.md @@ -0,0 +1,59 @@ +# Multi-Account Load Balancing + +When you add multiple accounts, the proxy intelligently distributes requests across them using configurable selection strategies. + +## Account Selection Strategies + +Choose a strategy based on your needs: + +| Strategy | Best For | Description | +| --- | --- | --- | +| **Hybrid** (Default) | Most users | Smart selection combining health score, token bucket rate limiting, quota awareness, and LRU freshness | +| **Sticky** | Prompt caching | Stays on the same account to maximize cache hits, switches only when rate-limited | +| **Round-Robin** | Even distribution | Cycles through accounts sequentially for balanced load | + +**Configure via CLI:** + +```bash +antigravity-claude-proxy start --strategy=hybrid # Default: smart distribution +antigravity-claude-proxy start --strategy=sticky # Cache-optimized +antigravity-claude-proxy start --strategy=round-robin # Load-balanced +``` + +**Or via WebUI:** Settings → Server → Account Selection Strategy + +## How It Works + +- **Health Score Tracking**: Accounts earn points for successful requests and lose points for failures/rate-limits +- **Token Bucket Rate Limiting**: Client-side throttling with regenerating tokens (50 max, 6/minute) +- **Quota Awareness**: Accounts below configurable quota thresholds are deprioritized; exhausted accounts trigger emergency fallback +- **Quota Protection**: Set minimum quota levels globally, per-account, or per-model to switch accounts before quota runs out +- **Emergency Fallback**: When all accounts appear exhausted, bypasses checks with throttle delays (250-500ms) +- **Automatic Cooldown**: Rate-limited accounts recover automatically after reset time expires +- **Invalid Account Detection**: Accounts needing re-authentication are marked and skipped +- **Prompt Caching Support**: Session IDs derived from conversation enable cache hits across turns + +## Monitoring + +Check account status, subscription tiers, and quota anytime: + +```bash +# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress) +# CLI Table: +curl "http://localhost:8080/account-limits?format=table" +``` + +### CLI Management Reference + +If you prefer using the terminal for management: + +```bash +# List all accounts +antigravity-claude-proxy accounts list + +# Verify account health +antigravity-claude-proxy accounts verify + +# Interactive CLI menu +antigravity-claude-proxy accounts +``` diff --git a/docs/menubar-app.md b/docs/menubar-app.md new file mode 100755 index 0000000000000000000000000000000000000000..e9452d5a584bb7ab7248f39c1a9d4968f11abd3b --- /dev/null +++ b/docs/menubar-app.md @@ -0,0 +1,17 @@ +# macOS Menu Bar App + +For macOS users who prefer a native experience, there's a companion menu bar app that provides quick access to server controls without touching the terminal. Get it from: [antigravity-claude-proxy-bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar) + +> **Note:** This is a GUI wrapper only. You still need to install and setup the proxy server first using one of the [installation methods](../README.md#installation) above. + +![AntiGravity Claude Proxy Bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar/blob/main/images/application.png?raw=true) + +## Key Features + +- **Server Control**: Start/stop the proxy server with a single click or ⌘S shortcut. +- **Status Indicator**: Menu bar icon shows server running state at a glance. +- **WebUI Access**: Open the web management console directly from the menu. +- **Port Configuration**: Customize the proxy server port (default: 8080). +- **Account Selection Strategy**: Choose between Hybrid, Sticky, or Round-Robin load balancing strategies. +- **Auto-Start Options**: Launch server on app start and launch app at login. +- **Native Experience**: Clean, native SwiftUI interface designed for macOS. diff --git a/docs/models.md b/docs/models.md new file mode 100755 index 0000000000000000000000000000000000000000..5143af12a8ac66849797d5930b345aa60215106a --- /dev/null +++ b/docs/models.md @@ -0,0 +1,19 @@ +# Available Models + +## Claude Models + +| Model ID | Description | +| ---------------------------- | ---------------------------------------- | +| `claude-sonnet-4-6-thinking` | Claude Sonnet 4.6 with extended thinking | +| `claude-opus-4-6-thinking` | Claude Opus 4.6 with extended thinking | +| `claude-sonnet-4-6` | Claude Sonnet 4.6 without thinking | + +## Gemini Models + +| Model ID | Description | +| ------------------- | ------------------------------- | +| `gemini-3-flash` | Gemini 3 Flash with thinking | +| `gemini-3.1-pro-low` | Gemini 3.1 Pro Low with thinking | +| `gemini-3.1-pro-high` | Gemini 3.1 Pro High with thinking | + +Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations. diff --git a/docs/openclaw.md b/docs/openclaw.md new file mode 100755 index 0000000000000000000000000000000000000000..0a90e6fbb8cb168002751c3c24aa81a50e3d5cf7 --- /dev/null +++ b/docs/openclaw.md @@ -0,0 +1,159 @@ +# Using with OpenClaw / ClawdBot + +[OpenClaw](https://docs.openclaw.ai/) (formerly ClawdBot/Moltbot) is an AI agent gateway that connects to messaging apps like Telegram, WhatsApp, Discord, Slack, and iMessage. You can configure it to use this proxy for Claude and Gemini models. + +## Prerequisites + +- OpenClaw installed (`npm install -g openclaw@latest`) +- Antigravity Claude Proxy running on port 8080 +- At least one Google account linked to the proxy + +## Configure OpenClaw + +Edit your OpenClaw config file at: +- **macOS/Linux**: `~/.openclaw/openclaw.json` +- **Windows**: `%USERPROFILE%\.openclaw\openclaw.json` + +Add the following configuration: + +```json +{ + "models": { + "mode": "merge", + "providers": { + "antigravity-proxy": { + "baseUrl": "http://127.0.0.1:8080", + "apiKey": "test", + "api": "anthropic-messages", + "models": [ + { + "id": "gemini-3-flash", + "name": "Gemini 3 Flash", + "reasoning": true, + "input": ["text", "image"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 1048576, + "maxTokens": 65536 + }, + { + "id": "gemini-3.1-pro-high", + "name": "Gemini 3.1 Pro High", + "reasoning": true, + "input": ["text", "image"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 1048576, + "maxTokens": 65536 + }, + { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "reasoning": false, + "input": ["text", "image"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 16384 + }, + { + "id": "claude-sonnet-4-6-thinking", + "name": "Claude Sonnet 4.6 Thinking", + "reasoning": true, + "input": ["text", "image"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 16384 + }, + { + "id": "claude-opus-4-6-thinking", + "name": "Claude Opus 4.6 Thinking", + "reasoning": true, + "input": ["text", "image"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 200000, + "maxTokens": 32000 + } + ] + } + } + }, + "agents": { + "defaults": { + "model": { + "primary": "antigravity-proxy/gemini-3-flash", + "fallbacks": ["antigravity-proxy/gemini-3.1-pro-high"] + }, + "models": { + "antigravity-proxy/gemini-3-flash": {} + } + } + } +} +``` + +> **Important**: Use `127.0.0.1` instead of `localhost` in the `baseUrl`. This ensures the connection stays on the loopback interface. If you're running on a VPS and accidentally start the proxy bound to `0.0.0.0`, using `localhost` in your client config could still work but `127.0.0.1` makes the intent explicit and avoids potential DNS resolution issues. + +## Start Both Services + +```bash +# Terminal 1: Start the proxy (binds to localhost only by default) +antigravity-claude-proxy start + +# Terminal 2: Start OpenClaw gateway +openclaw gateway +``` + +## Verify Configuration + +```bash +# Check available models +openclaw models list + +# Check gateway status +openclaw status +``` + +You should see models prefixed with `antigravity-proxy/` in the list. + +## Switch Models + +To change the default model: + +```bash +openclaw models set antigravity-proxy/claude-opus-4-6-thinking +``` + +Or edit the `model.primary` field in your config file. + +## Troubleshooting + +### Connection Refused + +Make sure the proxy is running before starting OpenClaw: +```bash +curl http://127.0.0.1:8080/health +``` + +### Models Not Showing + +1. Verify the config file is valid JSON +2. Check that `mode` is set to `"merge"` (not `"replace"` unless you want to override all built-in models) +3. Restart the OpenClaw gateway after config changes + +### VPS Security + +If running on a VPS, ensure the proxy only binds to localhost: +```bash +# Default binds to 0.0.0.0 (all interfaces) - exposed to network! +antigravity-claude-proxy start + +# Explicitly bind to localhost only (recommended for VPS) +HOST=127.0.0.1 antigravity-claude-proxy start +``` + +By default, the proxy binds to `0.0.0.0` which exposes it to all network interfaces. On a VPS, always use `HOST=127.0.0.1` to restrict access to localhost only, or ensure you have proper authentication (`API_KEY` env var) and firewall rules in place. + +## Further Reading + +- [OpenClaw Documentation](https://docs.openclaw.ai/) +- [OpenClaw Configuration Reference](https://docs.openclaw.ai/gateway/configuration) +- [Proxy Load Balancing](./load-balancing.md) +- [Proxy Configuration](./configuration.md) diff --git a/docs/opencode.md b/docs/opencode.md new file mode 100755 index 0000000000000000000000000000000000000000..47e7bb44fa5b19bba0f1841d904d890b56d4ca9f --- /dev/null +++ b/docs/opencode.md @@ -0,0 +1,150 @@ +# Using with OpenCode + +[OpenCode](https://opencode.ai/) is a terminal-based AI coding assistant similar to Claude Code. You can configure it to use this proxy, which unlocks both Claude and Gemini models in a single interactive session. + +## The Problem with Claude Code + +The proxy exposes both Claude and Gemini models via `/v1/models`. However, Claude Code's `/model` picker is hardcoded by Anthropic to only show Claude variants. Gemini models are effectively invisible in the UI. + +The only workaround within Claude Code is hardcoding a single Gemini model in `settings.json` — no interactive switching, no mid-session changes. + +## What OpenCode Solves + +OpenCode supports custom providers via a config file. Once pointed at this proxy, all models (Claude and Gemini) appear in one interactive picker and can be switched freely at any time. + +| Feature | Claude Code | OpenCode (with this proxy) | +|---|---|---| +| Claude models | yes | yes | +| Gemini models | hardcoded only | interactive picker | +| Switch models mid-session | no | yes | +| Multi-account load balancing | yes (via proxy) | yes (via proxy) | + +## Prerequisites + +- OpenCode installed (`npm install -g opencode-ai@latest`) +- Antigravity Claude Proxy running on port 8080 +- At least one Google account linked to the proxy + +## Global Configuration + +```bash +mkdir -p ~/.config/opencode + +cat > ~/.config/opencode/opencode.json <<'EOF' +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "antigravity": { + "npm": "@ai-sdk/anthropic", + "name": "Antigravity Local", + "options": { + "baseURL": "http://localhost:8080/v1", + "apiKey": "{env:ANTHROPIC_API_KEY}" + }, + "models": { + "claude-sonnet-4-6": { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6" }, + "claude-opus-4-6-thinking": { "id": "claude-opus-4-6-thinking", "name": "Claude Opus 4.6 Thinking" }, + "gemini-2.5-pro": { "id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro" }, + "gemini-2.5-flash": { "id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash" }, + "gemini-2.5-flash-lite": { "id": "gemini-2.5-flash-lite", "name": "Gemini 2.5 Flash Lite" }, + "gemini-2.5-flash-thinking": { "id": "gemini-2.5-flash-thinking", "name": "Gemini 2.5 Flash Thinking" }, + "gemini-3-flash": { "id": "gemini-3-flash", "name": "Gemini 3 Flash" }, + "gemini-3-flash-agent": { "id": "gemini-3-flash-agent", "name": "Gemini 3 Flash Agent" }, + "gemini-pro-agent": { "id": "gemini-pro-agent", "name": "Gemini 3.1 Pro Agent" }, + "gemini-3.1-pro-low": { "id": "gemini-3.1-pro-low", "name": "Gemini 3.1 Pro Low" }, + "gemini-3.1-flash-lite": { "id": "gemini-3.1-flash-lite", "name": "Gemini 3.1 Flash Lite" }, + "gemini-3.1-flash-image": { "id": "gemini-3.1-flash-image", "name": "Gemini 3.1 Flash Image" }, + "gemini-3.5-flash-low": { "id": "gemini-3.5-flash-low", "name": "Gemini 3.5 Flash" } + } + } + }, + "model": "antigravity/claude-sonnet-4-6", + "small_model": "antigravity/gemini-3.5-flash-low" +} +EOF +``` + +This config is global and applies automatically every time you run `opencode` from any folder. + +## Shell Environment + +```bash +grep -q 'ANTHROPIC_API_KEY=' ~/.zshrc || echo 'export ANTHROPIC_API_KEY="dummy"' >> ~/.zshrc +grep -q 'ANTHROPIC_BASE_URL=' ~/.zshrc || echo 'export ANTHROPIC_BASE_URL="http://localhost:8080/v1"' >> ~/.zshrc +source ~/.zshrc +``` + +The `ANTHROPIC_API_KEY` value can be anything — the proxy does not validate it. Authentication is handled via your linked Google accounts. + +## Start Both Services + +```bash +# Terminal 1: Start the proxy +antigravity-claude-proxy start + +# Terminal 2: Open OpenCode from any project folder +opencode +``` + +## Switching Models + +Inside OpenCode, all registered models appear as `antigravity/`. The `small_model` field handles background operations — setting it to a Gemini Flash model conserves your Claude quota, the same way `ANTHROPIC_DEFAULT_HAIKU_MODEL` works in Claude Code. + +## Available Models + +| Model ID | Type | Best For | +|---|---|---| +| `claude-sonnet-4-6` | Claude | Coding, refactoring | +| `claude-opus-4-6-thinking` | Claude | Architecture, deep reasoning | +| `gemini-2.5-pro` | Gemini | Long context, analysis | +| `gemini-2.5-flash` | Gemini | Fast general tasks | +| `gemini-2.5-flash-lite` | Gemini | Background tasks | +| `gemini-2.5-flash-thinking` | Gemini | Reasoning tasks | +| `gemini-3-flash` | Gemini | Fast, lightweight | +| `gemini-3-flash-agent` | Gemini | Agentic workflows | +| `gemini-pro-agent` | Gemini | Agentic workflows | +| `gemini-3.1-pro-low` | Gemini | Strong reasoning, 1M context | +| `gemini-3.1-flash-lite` | Gemini | Lightweight tasks | +| `gemini-3.1-flash-image` | Gemini | Vision/image tasks | +| `gemini-3.5-flash-low` | Gemini | Fast tasks, `small_model` recommended | + +## Verify Configuration + +```bash +curl "http://localhost:8080/v1/messages" + -H "content-type: application/json" + -H "anthropic-version: 2023-06-01" + -H "x-api-key: dummy" + -d '{"model": "claude-sonnet-4-6", "max_tokens": 100, "messages": [{"role":"user","content":"say hi"}]}' +``` + +## Troubleshooting + +### `Endpoint POST /messages not found` + +Your `baseURL` is missing `/v1`. Set it to `http://localhost:8080/v1`, not `http://localhost:8080`. + +### Model returns `400 INVALID_ARGUMENT` + +The model is rejected by Google's backend. Remove it from your `models` block. + +### Models not appearing in picker + +1. Check the config is valid JSON: `cat ~/.config/opencode/opencode.json` +2. Check the env var is set: `echo $ANTHROPIC_API_KEY` +3. Open a fresh terminal after config changes + +### Connection refused + +```bash +acc status +curl http://localhost:8080/health +``` + +## Further Reading + +- [OpenCode Documentation](https://opencode.ai/docs/) +- [OpenCode Providers](https://opencode.ai/docs/providers/) +- [Proxy Load Balancing](load-balancing.md) +- [Proxy Configuration](configuration.md) +- [Available Models](models.md) diff --git a/docs/safety-notices.md b/docs/safety-notices.md new file mode 100755 index 0000000000000000000000000000000000000000..4ebe8b8687f218f47e8059f792e57c9f6a3390ea --- /dev/null +++ b/docs/safety-notices.md @@ -0,0 +1,27 @@ +# Safety, Usage, and Risk Notices + +## Intended Use + +- Personal / internal development only +- Respect internal quotas and data handling policies +- Not for production services or bypassing intended limits + +## Not Suitable For + +- Production application traffic +- High-volume automated extraction +- Any use that violates Acceptable Use Policies + +## Warning (Assumption of Risk) + +By using this software, you acknowledge and accept the following: + +- **Terms of Service risk**: This approach may violate the Terms of Service of AI model providers (Anthropic, Google, etc.). You are solely responsible for ensuring compliance with all applicable terms and policies. + +- **Account risk**: Providers may detect this usage pattern and take punitive action, including suspension, permanent ban, or loss of access to paid subscriptions. + +- **No guarantees**: Providers may change APIs, authentication, or policies at any time, which can break this method without notice. + +- **Assumption of risk**: You assume all legal, financial, and technical risks. The authors and contributors of this project bear no responsibility for any consequences arising from your use. + +**Use at your own risk. Proceed only if you understand and accept these risks.** diff --git a/docs/testing.md b/docs/testing.md new file mode 100755 index 0000000000000000000000000000000000000000..0b0905174dd08a03488977c35dde5613ef3a95de --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,24 @@ +# Testing + +Run the test suite (requires server running): + +```bash +# Start server in one terminal +npm start + +# Run tests in another terminal +npm test +``` + +Individual tests: + +```bash +npm run test:signatures # Thinking signatures +npm run test:multiturn # Multi-turn with tools +npm run test:streaming # Streaming SSE events +npm run test:interleaved # Interleaved thinking +npm run test:images # Image processing +npm run test:caching # Prompt caching +npm run test:strategies # Account selection strategies +npm run test:cache-control # Cache control field stripping +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100755 index 0000000000000000000000000000000000000000..6efe7eb65df5d3f1739e5799efdef0d88f39a823 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,145 @@ +# Troubleshooting + +## Quick Links + +- [Windows: OAuth Port Error (EACCES)](#windows-oauth-port-error-eacces) +- ["Could not extract token from Antigravity"](#could-not-extract-token-from-antigravity) +- [401 Authentication Errors](#401-authentication-errors) +- [Rate Limiting (429)](#rate-limiting-429) +- [Account Shows as "Invalid"](#account-shows-as-invalid) +- [403 Permission Denied / VALIDATION_REQUIRED](#403-permission-denied--validation_required) + +--- + +## Windows: OAuth Port Error (EACCES) + +On Windows, the default OAuth callback port (51121) may be reserved by Hyper-V, WSL2, or Docker. If you see: + +``` +Error: listen EACCES: permission denied 0.0.0.0:51121 +``` + +The proxy will automatically try fallback ports (51122-51126). If all ports fail, try these solutions: + +### Option 1: Use a Custom Port (Recommended) + +Set a custom port outside the reserved range: + +```bash +# Windows PowerShell +$env:OAUTH_CALLBACK_PORT = "3456" +antigravity-claude-proxy start + +# Windows CMD +set OAUTH_CALLBACK_PORT=3456 +antigravity-claude-proxy start + +# Or add to your .env file +OAUTH_CALLBACK_PORT=3456 +``` + +### Option 2: Reset Windows NAT + +Run as Administrator: + +```powershell +net stop winnat +net start winnat +``` + +### Option 3: Check Reserved Ports + +See which ports are reserved: + +```powershell +netsh interface ipv4 show excludedportrange protocol=tcp +``` + +If 51121 is in a reserved range, use Option 1 with a port outside those ranges. + +### Option 4: Permanently Exclude Port (Admin) + +Reserve the port before Hyper-V claims it (run as Administrator): + +```powershell +netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1 +``` + +> **Note:** The server automatically tries fallback ports (51122-51126) if the primary port fails. + +--- + +## "Could not extract token from Antigravity" + +If using single-account mode with Antigravity: + +1. Make sure Antigravity app is installed and running +2. Ensure you're logged in to Antigravity + +Or add accounts via OAuth instead: `antigravity-claude-proxy accounts add` + +## 401 Authentication Errors + +The token might have expired. Try: + +```bash +curl -X POST http://localhost:8080/refresh-token +``` + +Or re-authenticate the account: + +```bash +antigravity-claude-proxy accounts +``` + +## Rate Limiting (429) + +With multiple accounts, the proxy automatically switches to the next available account. With a single account, you'll need to wait for the rate limit to reset. + +## Account Shows as "Invalid" + +Re-authenticate the account: + +```bash +antigravity-claude-proxy accounts +# Choose "Re-authenticate" for the invalid account +``` + +## 403 Permission Denied / VALIDATION_REQUIRED + +If you see: + +``` +403 VALIDATION_REQUIRED - Account requires verification +``` + +This means Google requires your account to complete verification (phone number, captcha, or terms acceptance). + +**The proxy handles this automatically:** + +1. The affected account is marked invalid and the proxy rotates to the next available account +2. If a verification URL is provided by Google, it's stored and shown in the WebUI +3. Other accounts continue working normally while the affected account is paused + +**To fix the affected account:** + +1. Open the WebUI (http://localhost:8080) +2. Find the account marked with an error badge +3. Click the **FIX** button — this opens the Google verification page directly +4. Complete the verification (phone number, captcha, etc.) +5. Click the **↻ Refresh** button on the account to re-enable it + +**If the FIX button opens an OAuth page instead** (no verification URL was provided), re-authenticate the account: + +```bash +antigravity-claude-proxy accounts +# Choose "Re-authenticate" for the invalid account +``` + +**If all accounts are invalid**, the proxy returns an error immediately instead of waiting indefinitely: + +``` +All accounts are invalid: Account requires verification. Visit the WebUI to fix them. +``` + +> **Note:** Verification errors persist across server restarts until resolved. Auth errors (token revoked/expired) are reset on restart and require OAuth re-authentication via the FIX button. diff --git a/docs/web-console.md b/docs/web-console.md new file mode 100755 index 0000000000000000000000000000000000000000..18af29e5ebbedd853ef34c3708044473b0212514 --- /dev/null +++ b/docs/web-console.md @@ -0,0 +1,20 @@ +# Web Management Console + +The proxy includes a built-in, modern web interface for real-time monitoring and configuration. Access the console at: `http://localhost:8080` (default port). + +![Antigravity Console](../images/webui-dashboard.png) + +## Key Features + +- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution. +- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators and draggable per-account threshold markers. +- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra), quota status, and per-account threshold settings. +- **Manual OAuth Mode**: Add accounts on headless servers by copying the OAuth URL and pasting the authorization code. +- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser. +- **Persistent History**: Tracks request volume by model family for 30 days, persisting across server restarts. +- **Time Range Filtering**: Analyze usage trends over 1H, 6H, 24H, 7D, or All Time periods. +- **Smart Analysis**: Auto-select top 5 most used models or toggle between Family/Model views. +- **Live Logs**: Stream server logs with level-based filtering and search. +- **Quota Protection**: Set global or per-account minimum quota thresholds to proactively switch accounts before quota runs out. +- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly. +- **Multi-language Interface**: Full support for English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR), and Turkish (Türkçe). diff --git a/package-lock.json b/package-lock.json new file mode 100755 index 0000000000000000000000000000000000000000..8d3757a8ab7661572cae7481f3592aadd116c4f0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2861 @@ +{ + "name": "antigravity-claude-proxy", + "version": "2.7.7", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "antigravity-claude-proxy", + "version": "2.7.7", + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "better-sqlite3": "^12.5.0", + "cors": "^2.8.5", + "express": "^4.18.2", + "undici": "^7.20.0" + }, + "bin": { + "acc": "bin/cli.js", + "antigravity-claude-proxy": "bin/cli.js" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.7", + "autoprefixer": "^10.4.16", + "concurrently": "^8.2.2", + "daisyui": "^4.12.14", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz", + "integrity": "sha512-FXI191x+D6UPWSze5IzZjhz+i9MK9nsuHsmTX9bXVl52k06AfZ2xql0lrgIUuzsMsJ7Vgl5kIptvDgBLIV3ZSQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/css-selector-tokenizer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", + "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/culori": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz", + "integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/daisyui": { + "version": "4.12.24", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.12.24.tgz", + "integrity": "sha512-JYg9fhQHOfXyLadrBrEqCDM6D5dWCSSiM6eTNCRrBRzx/VlOCrLS8eDfIw9RVvs64v2mJdLooKXY8EwQzoszAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-selector-tokenizer": "^0.8", + "culori": "^3", + "picocolors": "^1", + "postcss-js": "^4" + }, + "engines": { + "node": ">=16.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/daisyui" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "dev": true, + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici": { + "version": "7.20.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.20.0.tgz", + "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000000000000000000000000000000000000..9ed77de4c9373df3431a92545a1c061a0fc0c1bb --- /dev/null +++ b/package.json @@ -0,0 +1,78 @@ +{ + "name": "antigravity-claude-proxy", + "version": "2.7.7", + "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI", + "main": "src/index.js", + "type": "module", + "bin": { + "antigravity-claude-proxy": "bin/cli.js", + "acc": "bin/cli.js" + }, + "files": [ + "src", + "bin", + "public" + ], + "scripts": { + "build:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --minify", + "watch:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --watch", + "prepare": "npm run build:css", + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "dev:full": "concurrently \"npm run watch:css\" \"npm run dev\"", + "accounts": "node src/cli/accounts.js", + "accounts:add": "node src/cli/accounts.js add", + "accounts:list": "node src/cli/accounts.js list", + "accounts:remove": "node src/cli/accounts.js remove", + "accounts:verify": "node src/cli/accounts.js verify", + "test": "node tests/run-all.cjs", + "test:signatures": "node tests/test-thinking-signatures.cjs", + "test:multiturn": "node tests/test-multiturn-thinking-tools.cjs", + "test:streaming": "node tests/test-multiturn-thinking-tools-streaming.cjs", + "test:interleaved": "node tests/test-interleaved-thinking.cjs", + "test:images": "node tests/test-images.cjs", + "test:caching": "node tests/test-caching-streaming.cjs", + "test:crossmodel": "node tests/test-cross-model-thinking.cjs", + "test:oauth": "node tests/test-oauth-no-browser.cjs", + "test:emptyretry": "node tests/test-empty-response-retry.cjs", + "test:sanitizer": "node tests/test-schema-sanitizer.cjs", + "test:strategies": "node tests/test-strategies.cjs", + "test:cache-control": "node tests/test-cache-control.cjs", + "test:migration": "node --test tests/migration/*.test.js" + }, + "keywords": [ + "claude", + "anthropic", + "antigravity", + "proxy", + "vertex-ai" + ], + "author": "Badri Narayanan", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/badri-s2001/antigravity-claude-proxy.git" + }, + "homepage": "https://github.com/badri-s2001/antigravity-claude-proxy#readme", + "bugs": { + "url": "https://github.com/badri-s2001/antigravity-claude-proxy/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "async-mutex": "^0.5.0", + "better-sqlite3": "^12.5.0", + "cors": "^2.8.5", + "express": "^4.18.2", + "undici": "^7.20.0" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.7", + "autoprefixer": "^10.4.16", + "concurrently": "^8.2.2", + "daisyui": "^4.12.14", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100755 index 0000000000000000000000000000000000000000..2b75bd8a7e544d376bfb9e8b80b604c1beb2e466 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/public/app.js b/public/app.js new file mode 100755 index 0000000000000000000000000000000000000000..4ab5c9c8bdc27696801cc3cc3b714415f76dd8fa --- /dev/null +++ b/public/app.js @@ -0,0 +1,227 @@ +/** + * Antigravity Console - Main Entry + * + * This file orchestrates Alpine.js initialization. + * Components are loaded via separate script files that register themselves + * to window.Components before this script runs. + */ + +document.addEventListener('alpine:init', () => { + // Register Components (loaded from separate files via window.Components) + Alpine.data('dashboard', window.Components.dashboard); + Alpine.data('models', window.Components.models); + Alpine.data('accountManager', window.Components.accountManager); + Alpine.data('claudeConfig', window.Components.claudeConfig); + Alpine.data('logsViewer', window.Components.logsViewer); + Alpine.data('addAccountModal', window.Components.addAccountModal); + + // View Loader Directive + Alpine.directive('load-view', (el, { expression }, { evaluate }) => { + if (!window.viewCache) window.viewCache = new Map(); + + // Evaluate the expression to get the actual view name (removes quotes) + const viewName = evaluate(expression); + + if (window.viewCache.has(viewName)) { + el.innerHTML = window.viewCache.get(viewName); + Alpine.initTree(el); + return; + } + + fetch(`views/${viewName}.html?t=${Date.now()}`) + .then(response => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); + }) + .then(html => { + // Update cache (optional, or remove if we want always-fresh) + // keeping cache for session performance, but initial load will now bypass browser cache + window.viewCache.set(viewName, html); + el.innerHTML = html; + Alpine.initTree(el); + }) + .catch(err => { + console.error('Failed to load view:', viewName, err); + el.innerHTML = `
+ Error loading view: ${viewName}
+ ${err.message} +
`; + }); + }); + + // Main App Controller + Alpine.data('app', () => ({ + get connectionStatus() { + return Alpine.store('data')?.connectionStatus || 'connecting'; + }, + get loading() { + return Alpine.store('data')?.loading || false; + }, + + sidebarOpen: window.innerWidth >= 1024, + toggleSidebar() { + this.sidebarOpen = !this.sidebarOpen; + }, + + init() { + // Handle responsive sidebar transitions + let lastWidth = window.innerWidth; + let resizeTimeout = null; + + window.addEventListener('resize', () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + + resizeTimeout = setTimeout(() => { + const currentWidth = window.innerWidth; + const lgBreakpoint = 1024; + + // Desktop -> Mobile: Auto-close sidebar to prevent overlay blocking screen + if (lastWidth >= lgBreakpoint && currentWidth < lgBreakpoint) { + this.sidebarOpen = false; + } + + // Mobile -> Desktop: Auto-open sidebar (restore standard desktop layout) + if (lastWidth < lgBreakpoint && currentWidth >= lgBreakpoint) { + this.sidebarOpen = true; + } + + lastWidth = currentWidth; + }, 150); + }); + + // Theme setup + document.documentElement.setAttribute('data-theme', 'black'); + document.documentElement.classList.add('dark'); + + // Chart Defaults + if (typeof Chart !== 'undefined') { + Chart.defaults.color = window.utils.getThemeColor('--color-text-dim'); + Chart.defaults.borderColor = window.utils.getThemeColor('--color-space-border'); + Chart.defaults.font.family = '"JetBrains Mono", monospace'; + } + + // Start Data Polling + this.startAutoRefresh(); + document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh()); + + // Initial Fetch + Alpine.store('data').fetchData(); + }, + + refreshTimer: null, + + fetchData() { + Alpine.store('data').fetchData(); + }, + + startAutoRefresh() { + if (this.refreshTimer) clearInterval(this.refreshTimer); + const interval = parseInt(Alpine.store('settings')?.refreshInterval || 60); + if (interval > 0) { + this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000); + } + }, + + t(key) { + return Alpine.store('global')?.t(key) || key; + }, + + async addAccountWeb(reAuthEmail = null) { + const password = Alpine.store('global').webuiPassword; + try { + const urlPath = reAuthEmail + ? `/api/auth/url?email=${encodeURIComponent(reAuthEmail)}` + : '/api/auth/url'; + + const { response, newPassword } = await window.utils.request(urlPath, {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + const data = await response.json(); + + if (data.status === 'ok') { + // Show info toast that OAuth is in progress + Alpine.store('global').showToast(Alpine.store('global').t('oauthInProgress'), 'info'); + + // Open OAuth window + const oauthWindow = window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes'); + + // Poll for account changes instead of relying on postMessage + // (since OAuth callback is now on port 51121, not this server) + const initialAccountCount = Alpine.store('data').accounts.length; + let pollCount = 0; + const maxPolls = 60; // 2 minutes (2 second intervals) + let cancelled = false; + + // Show progress modal + Alpine.store('global').oauthProgress = { + active: true, + current: 0, + max: maxPolls, + cancel: () => { + cancelled = true; + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + Alpine.store('global').showToast(Alpine.store('global').t('oauthCancelled'), 'info'); + if (oauthWindow && !oauthWindow.closed) { + oauthWindow.close(); + } + } + }; + + const pollInterval = setInterval(async () => { + if (cancelled) { + clearInterval(pollInterval); + return; + } + + pollCount++; + Alpine.store('global').oauthProgress.current = pollCount; + + // Check if OAuth window was closed manually + if (oauthWindow && oauthWindow.closed && !cancelled) { + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + Alpine.store('global').showToast(Alpine.store('global').t('oauthWindowClosed'), 'warning'); + return; + } + + // Refresh account list + await Alpine.store('data').fetchData(); + + // Check if new account was added + const currentAccountCount = Alpine.store('data').accounts.length; + if (currentAccountCount > initialAccountCount) { + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + + const actionKey = reAuthEmail ? 'accountReauthSuccess' : 'accountAddedSuccess'; + Alpine.store('global').showToast( + Alpine.store('global').t(actionKey), + 'success' + ); + document.getElementById('add_account_modal')?.close(); + + if (oauthWindow && !oauthWindow.closed) { + oauthWindow.close(); + } + } + + // Stop polling after max attempts + if (pollCount >= maxPolls) { + clearInterval(pollInterval); + Alpine.store('global').oauthProgress.active = false; + Alpine.store('global').showToast( + Alpine.store('global').t('oauthTimeout'), + 'warning' + ); + } + }, 2000); // Poll every 2 seconds + } else { + Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error'); + } + } catch (e) { + Alpine.store('global').showToast(Alpine.store('global').t('failedToStartOAuth') + ': ' + e.message, 'error'); + } + } + })); +}); \ No newline at end of file diff --git a/public/css/src/input.css b/public/css/src/input.css new file mode 100755 index 0000000000000000000000000000000000000000..8dfaae4d2e73f2e070e8c9e63f25933e3e8c6d2f --- /dev/null +++ b/public/css/src/input.css @@ -0,0 +1,523 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + /* === Background Layers === */ + --color-space-950: #09090b; + --color-space-900: #0f0f11; + --color-space-850: #121214; + --color-space-800: #18181b; + --color-space-border: #27272a; + + /* === Neon Accents (Full Saturation) === */ + --color-neon-purple: #a855f7; + --color-neon-green: #22c55e; + --color-neon-cyan: #06b6d4; + --color-neon-yellow: #eab308; + --color-neon-red: #ef4444; + + /* === Soft Neon (Reduced Saturation for Fills) === */ + --color-neon-purple-soft: #9333ea; + --color-neon-green-soft: #16a34a; + --color-neon-cyan-soft: #0891b2; + + /* === Text Hierarchy (WCAG AA Compliant) === */ + --color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */ + --color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */ + --color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */ + --color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */ + + /* === Legacy Aliases (Backward Compatibility) === */ + --color-text-main: var(--color-text-secondary); + --color-text-dim: var(--color-text-tertiary); + --color-text-muted: var(--color-text-tertiary); + --color-text-bright: var(--color-text-primary); + + /* Gradient Accents */ + --color-green-400: #4ade80; + --color-yellow-400: #facc15; + --color-red-400: #f87171; + + /* Chart Colors */ + --color-chart-1: #a855f7; + --color-chart-2: #c084fc; + --color-chart-3: #e879f9; + --color-chart-4: #d946ef; + --color-chart-5: #22c55e; + --color-chart-6: #4ade80; + --color-chart-7: #86efac; + --color-chart-8: #10b981; + --color-chart-9: #06b6d4; + --color-chart-10: #f59e0b; + --color-chart-11: #ef4444; + --color-chart-12: #ec4899; + --color-chart-13: #8b5cf6; + --color-chart-14: #14b8a6; + --color-chart-15: #f97316; + --color-chart-16: #6366f1; +} + +[x-cloak] { + display: none !important; +} + +/* Custom Scrollbar */ +.custom-scrollbar { + scrollbar-gutter: stable; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(9, 9, 11, 0.3); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #27272a 0%, #18181b 100%); + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: background 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%); + border-color: rgba(168, 85, 247, 0.3); +} + +/* Animations */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.2s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fadeIn 0.4s ease-out forwards; +} + +.nav-item.active { + background: linear-gradient( + 90deg, + theme("colors.neon.purple / 15%") 0%, + transparent 100% + ); + @apply border-l-4 border-neon-purple text-white; +} + +.nav-item { + @apply border-l-4 border-transparent transition-all duration-200; +} + +.progress-gradient-success::-webkit-progress-value { + background-image: linear-gradient( + to right, + var(--color-neon-green), + var(--color-green-400) + ); +} + +.progress-gradient-warning::-webkit-progress-value { + background-image: linear-gradient( + to right, + var(--color-neon-yellow), + var(--color-yellow-400) + ); +} + +.progress-gradient-error::-webkit-progress-value { + background-image: linear-gradient( + to right, + var(--color-neon-red), + var(--color-red-400) + ); +} + +/* Dashboard Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.5rem; +} + +/* Tooltip Customization */ +.tooltip:before { + @apply bg-space-800 border border-space-border text-gray-200 font-mono text-xs; +} + +.tooltip-left:before { + margin-right: 0.5rem; +} + +/* -------------------------------------------------------------------------- */ +/* Refactored Global Utilities */ +/* -------------------------------------------------------------------------- */ + +/* Standard Layout Constants */ +:root { + --view-padding: 2rem; /* 32px - Standard Padding */ + --view-gap: 2rem; /* 32px - Standard component gap */ + --card-radius: 0.75rem; /* 12px */ +} + +@media (max-width: 768px) { + :root { + --view-padding: 1rem; + --view-gap: 1.25rem; + } +} + +/* Base View Container */ +.view-container { + display: flex; + flex-direction: column; + margin-left: auto; + margin-right: auto; + width: 100%; + padding: var(--view-padding); + gap: var(--view-gap); + min-height: calc(100vh - 56px); /* Align with navbar height */ + max-width: 1400px; + scrollbar-gutter: stable; +} + +/* Specialized container for data-heavy pages (Logs) */ +.view-container-full { + @apply w-full animate-fade-in flex flex-col; + padding: var(--view-padding); + gap: var(--view-gap); + min-height: calc(100vh - 56px); + max-width: 100%; +} + +/* Centered container for form-heavy pages (Settings/Accounts) */ +.view-container-centered { + @apply mx-auto w-full animate-fade-in flex flex-col; + padding: var(--view-padding); + gap: var(--view-gap); + min-height: calc(100vh - 56px); + max-width: 900px; /* Comfortable reading width for forms */ +} + +/* Standard Section Header */ +.view-header { + display: flex; + flex-direction: column; + justify-content: space-between; + margin-bottom: 0.5rem; + gap: 1rem; +} + +@media (min-width: 768px) { + .view-header { + flex-direction: row; + align-items: flex-end; + } +} + +.view-header-title { + @apply flex flex-col; +} + +.view-header-title h2 { + @apply text-2xl font-bold text-white tracking-tight; +} + +.view-header-title p { + @apply text-sm text-gray-500 mt-1; +} + +.view-header-actions { + @apply flex items-center gap-3; +} + +/* Standard Card Panel */ +.view-card { + position: relative; + overflow: hidden; + border-radius: var(--card-radius); + padding: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(135deg, + rgba(15, 15, 17, 0.75) 0%, + rgba(18, 18, 20, 0.70) 100% + ); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.02) inset, + 0 4px 24px rgba(0, 0, 0, 0.4); + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.view-card:hover { + border-color: rgba(255, 255, 255, 0.12); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.04) inset, + 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.view-card-header { + @apply flex items-center justify-between mb-4 pb-4 border-b border-[rgba(39,39,42,0.3)]; +} + +/* Component Unification */ +.standard-table { + @apply table w-full border-separate border-spacing-0; +} +.standard-table thead { + @apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border; +} +.standard-table tbody tr { + @apply transition-all duration-200 border-b border-[rgba(39,39,42,0.3)] last:border-0; +} + +.standard-table tbody tr:hover { + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.03) 0%, + rgba(255, 255, 255, 0.05) 50%, + rgba(255, 255, 255, 0.03) 100% + ); + border-color: rgba(255, 255, 255, 0.08); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* Custom Range Slider - Simplified */ +.custom-range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 4px; + background: var(--color-space-800); + border-radius: 999px; + outline: none; + cursor: pointer; +} + +.custom-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--range-color, var(--color-neon-purple)); + cursor: pointer; + transition: transform 0.1s ease; +} + +.custom-range::-webkit-slider-thumb:hover { + transform: scale(1.15); +} + +.custom-range::-moz-range-thumb { + width: 14px; + height: 14px; + border: none; + border-radius: 50%; + background: var(--range-color, var(--color-neon-purple)); + cursor: pointer; + transition: transform 0.1s ease; +} + +.custom-range::-moz-range-thumb:hover { + transform: scale(1.15); +} + +/* Color Variants */ +.custom-range-purple { + --range-color: var(--color-neon-purple); +} +.custom-range-green { + --range-color: var(--color-neon-green); +} +.custom-range-cyan { + --range-color: var(--color-neon-cyan); +} +.custom-range-yellow { + --range-color: var(--color-neon-yellow); +} +.custom-range-accent { + --range-color: var(--color-neon-cyan); +} + +/* -------------------------------------------------------------------------- */ +/* Refactored UI Components (Phase 1.2) */ +/* -------------------------------------------------------------------------- */ + +/* Phase 1.2 additions ... */ + +/* Filter Controls */ +.filter-control { + @apply flex items-center justify-center gap-2 px-3 py-1.5 lg:px-4 lg:py-2 + text-[10px] lg:text-xs font-mono font-medium text-gray-400 + bg-space-800 lg:bg-transparent border border-space-border/50 lg:border-transparent + rounded lg:rounded-md hover:text-white lg:hover:bg-space-800 + transition-all duration-200 whitespace-nowrap w-full sm:w-auto; +} + +.filter-control:hover { + @apply border-neon-cyan/50 lg:border-neon-cyan/30 lg:shadow-lg lg:shadow-neon-cyan/10; +} + +.filter-control-item { + @apply block w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors; +} + +/* Action Buttons */ +.btn-action-ghost { + @apply btn btn-xs btn-ghost text-gray-400 hover:text-white transition-colors; +} + +.btn-action-ghost-square { + @apply btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white transition-colors; +} + +.btn-action-primary { + @apply btn bg-gradient-to-r from-neon-purple to-indigo-600 + border-none text-white shadow-lg shadow-neon-purple/20 + hover:shadow-neon-purple/40 transition-all; +} + +.btn-action-success { + @apply btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20; +} + +.btn-action-danger { + @apply btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20; +} + +.btn-action-neutral { + @apply btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20; +} + +/* Status Pills/Badges */ +.status-pill { + @apply px-2 py-1 text-[10px] font-mono font-bold uppercase rounded border; +} + +.status-pill-purple { + @apply status-pill bg-neon-purple/10 text-neon-purple border-neon-purple/30; +} + +.status-pill-ultra { + @apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30; +} + +.status-pill-pro { + @apply status-pill bg-blue-500/10 text-blue-400 border-blue-500/30; +} + +.status-pill-free { + @apply status-pill bg-gray-500/10 text-gray-400 border-gray-500/30; +} + +.status-pill-success { + @apply status-pill bg-neon-green/10 text-neon-green border-neon-green/30; +} + +.status-pill-warning { + @apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30; +} + +.status-pill-error { + @apply status-pill bg-red-500/10 text-red-400 border-red-500/30; +} + +/* Input Components */ +.input-search { + @apply w-full bg-space-900/50 border border-[rgba(39,39,42,0.5)] text-gray-300 + rounded-lg pl-10 pr-4 py-2 + focus:outline-none focus:bg-space-800 focus:border-neon-purple/50 + hover:border-space-border hover:bg-space-800/80 + transition-all placeholder-gray-600/80; +} + +.input-search-sm { + @apply input-search h-8 text-xs font-normal; +} + +/* -------------------------------------------------------------------------- */ +/* Skeleton Loading (Phase 4.1) */ +/* -------------------------------------------------------------------------- */ + +/* Skeleton animation */ +@keyframes skeleton-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +/* Base skeleton element */ +.skeleton { + @apply bg-gradient-to-r from-space-900/60 via-space-800/40 to-space-900/60; + background-size: 200% 100%; + animation: skeleton-pulse 1.5s ease-in-out infinite; + border-radius: 0.375rem; +} + +/* Skeleton variants */ +.skeleton-text { + @apply skeleton h-4 w-full; +} + +.skeleton-text-sm { + @apply skeleton h-3 w-3/4; +} + +.skeleton-title { + @apply skeleton h-6 w-1/2; +} + +.skeleton-circle { + @apply skeleton rounded-full; +} + +.skeleton-stat-card { + @apply skeleton h-32 w-full rounded-xl; +} + +.skeleton-chart { + @apply skeleton h-64 w-full rounded-xl; +} + +.skeleton-table-row { + @apply skeleton h-12 w-full mb-2; +} + +/* Fix DaisyUI toggle hover background override */ +.toggle-success:checked, +.toggle-success[aria-checked="true"] { + background-color: oklch(var(--su)) !important; +} + +/* Desktop Sidebar Collapsed State */ +@media (min-width: 1024px) { + body .sidebar-collapsed { + width: 0; + padding: 0; + border: none; + } +} \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css new file mode 100755 index 0000000000000000000000000000000000000000..524381090d081261a12eff6799fa1619fc464727 Binary files /dev/null and b/public/css/style.css differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100755 index 0000000000000000000000000000000000000000..36dd62c8c0c9169438f774e3f9836cbc74737b9c --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + AG + diff --git a/public/index.html b/public/index.html new file mode 100755 index 0000000000000000000000000000000000000000..cbd0a195f118f7cb4c68ed688bc5bcd2e7edfaf0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,434 @@ + + + + + + + Antigravity Console + + + + + + + + + + + + + + +
+ +
+ + +
+
+ + + +
+ AG
+
+ ANTIGRAVITY +
+ CLAUDE PROXY SYSTEM + v1.0.0 +
+
+
+ +
+ +
+
+
+ +
+ +
+ + + +
+
+ + +
+ + + + + +
+ + +
+ +
+ Menu + +
+ + + + + + +
System
+ + + + +
+
+ + +
+ + + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/js/components/account-manager.js b/public/js/components/account-manager.js new file mode 100755 index 0000000000000000000000000000000000000000..624bd71cceeab687f8df24b9cc7bfc4090a2c179 --- /dev/null +++ b/public/js/components/account-manager.js @@ -0,0 +1,502 @@ +/** + * Account Manager Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.accountManager = () => ({ + searchQuery: '', + deleteTarget: '', + refreshing: false, + toggling: false, + deleting: false, + reloading: false, + selectedAccountEmail: '', + selectedAccountLimits: {}, + + // Health Inspector (Developer Mode) + healthData: {}, + healthLoading: false, + + init() { + if (Alpine.store('data').devMode && Alpine.store('settings').healthInspectorOpen) { + this.fetchHealthData(); + } + }, + + get filteredAccounts() { + const accounts = Alpine.store('data').accounts || []; + if (!this.searchQuery || this.searchQuery.trim() === '') { + return accounts; + } + + const query = this.searchQuery.toLowerCase().trim(); + return accounts.filter(acc => { + return acc.email.toLowerCase().includes(query) || + (acc.projectId && acc.projectId.toLowerCase().includes(query)) || + (acc.source && acc.source.toLowerCase().includes(query)); + }); + }, + + formatEmail(email) { + if (!email || email.length <= 40) return email; + + const [user, domain] = email.split('@'); + if (!domain) return email; + + // Preserve domain integrity, truncate username if needed + if (user.length > 20) { + return `${user.substring(0, 10)}...${user.slice(-5)}@${domain}`; + } + return email; + }, + + async refreshAccount(email) { + return await window.ErrorHandler.withLoading(async () => { + const store = Alpine.store('global'); + store.showToast(store.t('refreshingAccount', { email: Redact.email(email) }), 'info'); + + const { response, newPassword } = await window.utils.request( + `/api/accounts/${encodeURIComponent(email)}/refresh`, + { method: 'POST' }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('refreshedAccount', { email: Redact.email(email) }), 'success'); + Alpine.store('data').fetchData(); + } else { + throw new Error(data.error || store.t('refreshFailed')); + } + }, this, 'refreshing', { errorMessage: 'Failed to refresh account' }); + }, + + async toggleAccount(email, enabled) { + const store = Alpine.store('global'); + const password = store.webuiPassword; + + // Optimistic update: immediately update UI + const dataStore = Alpine.store('data'); + const account = dataStore.accounts.find(a => a.email === email); + if (account) { + account.enabled = enabled; + } + + try { + const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }, password); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus'); + store.showToast(store.t('accountToggled', { email: Redact.email(email), status }), 'success'); + // Refresh to confirm server state + await dataStore.fetchData(); + } else { + store.showToast(data.error || store.t('toggleFailed'), 'error'); + // Rollback optimistic update on error + if (account) { + account.enabled = !enabled; + } + await dataStore.fetchData(); + } + } catch (e) { + store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error'); + // Rollback optimistic update on error + if (account) { + account.enabled = !enabled; + } + await dataStore.fetchData(); + } + }, + + async fixAccount(email) { + const store = Alpine.store('global'); + const dataStore = Alpine.store('data'); + // If the account has a verification URL (403 VALIDATION_REQUIRED), open it directly + const account = (dataStore.accounts || []).find(a => a.email === email); + if (account?.verifyUrl) { + window.open(account.verifyUrl, '_blank'); + store.showToast(store.t('verifyThenRefresh') || 'After completing verification, click the ↻ Refresh button to re-enable this account', 'info', 10000); + return; + } + // Otherwise fall back to OAuth re-auth + store.showToast(store.t('reauthenticating', { email: Redact.email(email) }), 'info'); + const password = store.webuiPassword; + try { + const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`; + const { response, newPassword } = await window.utils.request(urlPath, {}, password); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes'); + } else { + store.showToast(data.error || store.t('authUrlFailed'), 'error'); + } + } catch (e) { + store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error'); + } + }, + + confirmDeleteAccount(email) { + this.deleteTarget = email; + document.getElementById('delete_account_modal').showModal(); + }, + + async executeDelete() { + const email = this.deleteTarget; + return await window.ErrorHandler.withLoading(async () => { + const store = Alpine.store('global'); + + const { response, newPassword } = await window.utils.request( + `/api/accounts/${encodeURIComponent(email)}`, + { method: 'DELETE' }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('deletedAccount', { email: Redact.email(email) }), 'success'); + Alpine.store('data').fetchData(); + document.getElementById('delete_account_modal').close(); + this.deleteTarget = ''; + } else { + throw new Error(data.error || store.t('deleteFailed')); + } + }, this, 'deleting', { errorMessage: 'Failed to delete account' }); + }, + + async reloadAccounts() { + return await window.ErrorHandler.withLoading(async () => { + const store = Alpine.store('global'); + + const { response, newPassword } = await window.utils.request( + '/api/accounts/reload', + { method: 'POST' }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('accountsReloaded'), 'success'); + Alpine.store('data').fetchData(); + } else { + throw new Error(data.error || store.t('reloadFailed')); + } + }, this, 'reloading', { errorMessage: 'Failed to reload accounts' }); + }, + + openQuotaModal(account) { + this.selectedAccountEmail = account.email; + this.selectedAccountLimits = account.limits || {}; + document.getElementById('quota_modal').showModal(); + }, + + // Threshold settings + thresholdDialog: { + email: '', + quotaThreshold: null, // null means use global + modelQuotaThresholds: {}, + saving: false, + addingModel: false, + newModelId: '', + newModelThreshold: 10 + }, + + openThresholdModal(account) { + this.thresholdDialog = { + email: account.email, + // Convert from fraction (0-1) to percentage (0-99) for display + quotaThreshold: account.quotaThreshold !== undefined ? Math.round(account.quotaThreshold * 100) : null, + modelQuotaThresholds: Object.fromEntries( + Object.entries(account.modelQuotaThresholds || {}).map(([k, v]) => [k, Math.round(v * 100)]) + ), + saving: false, + addingModel: false, + newModelId: '', + newModelThreshold: 10 + }; + document.getElementById('threshold_modal').showModal(); + }, + + async saveAccountThreshold() { + const store = Alpine.store('global'); + this.thresholdDialog.saving = true; + + try { + // Convert percentage back to fraction + const quotaThreshold = this.thresholdDialog.quotaThreshold !== null && this.thresholdDialog.quotaThreshold !== '' + ? parseFloat(this.thresholdDialog.quotaThreshold) / 100 + : null; + + // Convert model thresholds from percentage to fraction + const modelQuotaThresholds = {}; + for (const [modelId, pct] of Object.entries(this.thresholdDialog.modelQuotaThresholds)) { + modelQuotaThresholds[modelId] = parseFloat(pct) / 100; + } + + const { response, newPassword } = await window.utils.request( + `/api/accounts/${encodeURIComponent(this.thresholdDialog.email)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ quotaThreshold, modelQuotaThresholds }) + }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast('Settings saved', 'success'); + Alpine.store('data').fetchData(); + document.getElementById('threshold_modal').close(); + } else { + throw new Error(data.error || 'Failed to save settings'); + } + } catch (e) { + store.showToast('Failed to save settings: ' + e.message, 'error'); + } finally { + this.thresholdDialog.saving = false; + } + }, + + clearAccountThreshold() { + this.thresholdDialog.quotaThreshold = null; + }, + + // Per-model threshold methods + addModelThreshold() { + this.thresholdDialog.addingModel = true; + this.thresholdDialog.newModelId = ''; + this.thresholdDialog.newModelThreshold = 10; + }, + + updateModelThreshold(modelId, value) { + const numValue = parseInt(value); + if (!isNaN(numValue) && numValue >= 0 && numValue <= 99) { + this.thresholdDialog.modelQuotaThresholds[modelId] = numValue; + } + }, + + removeModelThreshold(modelId) { + delete this.thresholdDialog.modelQuotaThresholds[modelId]; + }, + + confirmAddModelThreshold() { + const modelId = this.thresholdDialog.newModelId; + const threshold = parseInt(this.thresholdDialog.newModelThreshold) || 10; + + if (modelId && threshold >= 0 && threshold <= 99) { + this.thresholdDialog.modelQuotaThresholds[modelId] = threshold; + this.thresholdDialog.addingModel = false; + this.thresholdDialog.newModelId = ''; + this.thresholdDialog.newModelThreshold = 10; + } + }, + + getAvailableModelsForThreshold() { + // Get models from data store, exclude already configured ones + const allModels = Alpine.store('data').models || []; + const configured = Object.keys(this.thresholdDialog.modelQuotaThresholds); + return allModels.filter(m => !configured.includes(m)); + }, + + getEffectiveThreshold(account) { + // Return display string for effective threshold + if (account.quotaThreshold !== undefined) { + return Math.round(account.quotaThreshold * 100) + '%'; + } + // If no per-account threshold, show global value + const globalThreshold = Alpine.store('data').globalQuotaThreshold; + if (globalThreshold > 0) { + return Math.round(globalThreshold * 100) + '% (global)'; + } + return 'Global'; + }, + + /** + * Get main model quota for display + * Prioritizes flagship models (Opus > Sonnet > Flash) + * @param {Object} account - Account object with limits + * @returns {Object} { percent: number|null, model: string } + */ + getMainModelQuota(account) { + const limits = account.limits || {}; + + const getQuotaVal = (id) => { + const l = limits[id]; + if (!l) return -1; + if (l.remainingFraction !== null) return l.remainingFraction; + if (l.resetTime) return 0; // Rate limited + return -1; // Unknown + }; + + const validIds = Object.keys(limits).filter(id => getQuotaVal(id) >= 0); + + if (validIds.length === 0) return { percent: null, model: '-' }; + + const DEAD_THRESHOLD = 0.01; + + const MODEL_TIERS = [ + { pattern: /\bopus\b/, aliveScore: 100, deadScore: 60 }, + { pattern: /\bsonnet\b/, aliveScore: 90, deadScore: 55 }, + // Gemini 3 Pro / Ultra + { pattern: /\bgemini-3\b/, extraCheck: (l) => /\bpro\b/.test(l) || /\bultra\b/.test(l), aliveScore: 80, deadScore: 50 }, + { pattern: /\bpro\b/, aliveScore: 75, deadScore: 45 }, + // Mid/Low Tier + { pattern: /\bhaiku\b/, aliveScore: 30, deadScore: 15 }, + { pattern: /\bflash\b/, aliveScore: 20, deadScore: 10 } + ]; + + const getPriority = (id) => { + const lower = id.toLowerCase(); + const val = getQuotaVal(id); + const isAlive = val > DEAD_THRESHOLD; + + for (const tier of MODEL_TIERS) { + if (tier.pattern.test(lower)) { + if (tier.extraCheck && !tier.extraCheck(lower)) continue; + return isAlive ? tier.aliveScore : tier.deadScore; + } + } + + return isAlive ? 5 : 0; + }; + + // Sort by priority desc + validIds.sort((a, b) => getPriority(b) - getPriority(a)); + + const bestModel = validIds[0]; + const val = getQuotaVal(bestModel); + + return { + percent: Math.round(val * 100), + model: bestModel + }; + }, + + /** + * Fetch strategy health data for the inspector panel + */ + async fetchHealthData() { + this.healthLoading = true; + try { + const store = Alpine.store('global'); + const { response, newPassword } = await window.utils.request( + '/api/strategy/health', + {}, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + this.healthData = data; + } else { + this.healthData = {}; + if (response.status === 403) { + store.showToast(data.error || 'Developer mode is not enabled', 'warning'); + } + } + } catch (e) { + console.error('Failed to fetch health data:', e); + } finally { + this.healthLoading = false; + } + }, + + /** + * Export accounts to JSON file + */ + async exportAccounts() { + const store = Alpine.store('global'); + try { + const { response, newPassword } = await window.utils.request( + '/api/accounts/export', + {}, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + // API returns plain array directly + if (Array.isArray(data)) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `antigravity-accounts-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + store.showToast(store.t('exportSuccess', { count: data.length }), 'success'); + } else if (data.error) { + throw new Error(data.error); + } + } catch (e) { + store.showToast(store.t('exportFailed') + ': ' + e.message, 'error'); + } + }, + + /** + * Import accounts from JSON file + * @param {Event} event - file input change event + */ + async importAccounts(event) { + const store = Alpine.store('global'); + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const importData = JSON.parse(text); + + // Support both plain array and wrapped format + const accounts = Array.isArray(importData) ? importData : (importData.accounts || []); + if (!Array.isArray(accounts) || accounts.length === 0) { + throw new Error('Invalid file format: expected accounts array'); + } + + const { response, newPassword } = await window.utils.request( + '/api/accounts/import', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(accounts) + }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const { added, updated, failed } = data.results; + let msg = store.t('importSuccess') + ` ${added.length} added, ${updated.length} updated`; + if (failed.length > 0) { + msg += `, ${failed.length} failed`; + } + store.showToast(msg, failed.length > 0 ? 'info' : 'success'); + Alpine.store('data').fetchData(); + } else { + throw new Error(data.error || 'Import failed'); + } + } catch (e) { + store.showToast(store.t('importFailed') + ': ' + e.message, 'error'); + } finally { + // Reset file input + event.target.value = ''; + } + } +}); diff --git a/public/js/components/add-account-modal.js b/public/js/components/add-account-modal.js new file mode 100755 index 0000000000000000000000000000000000000000..0b17498f4bf93658c5a0a4cf0a3a76e4a90484e5 --- /dev/null +++ b/public/js/components/add-account-modal.js @@ -0,0 +1,88 @@ +/** + * Add Account Modal Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.addAccountModal = () => ({ + manualMode: false, + authUrl: '', + authState: '', + callbackInput: '', + submitting: false, + + /** + * Reset all state to initial values + */ + resetState() { + this.manualMode = false; + this.authUrl = ''; + this.authState = ''; + this.callbackInput = ''; + this.submitting = false; + // Close any open details elements + const details = document.querySelectorAll('#add_account_modal details[open]'); + details.forEach(d => d.removeAttribute('open')); + }, + + async copyLink() { + if (!this.authUrl) return; + await navigator.clipboard.writeText(this.authUrl); + Alpine.store('global').showToast(Alpine.store('global').t('linkCopied'), 'success'); + }, + + async initManualAuth(event) { + if (event.target.open && !this.authUrl) { + try { + const password = Alpine.store('global').webuiPassword; + const { + response, + newPassword + } = await window.utils.request('/api/auth/url', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + const data = await response.json(); + if (data.status === 'ok') { + this.authUrl = data.url; + this.authState = data.state; + } + } catch (e) { + Alpine.store('global').showToast(e.message, 'error'); + } + } + }, + + async completeManualAuth() { + if (!this.callbackInput || !this.authState) return; + this.submitting = true; + try { + const store = Alpine.store('global'); + const { + response, + newPassword + } = await window.utils.request('/api/auth/complete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + callbackInput: this.callbackInput, + state: this.authState + }) + }, store.webuiPassword); + if (newPassword) store.webuiPassword = newPassword; + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('accountAddedSuccess'), 'success'); + Alpine.store('data').fetchData(); + document.getElementById('add_account_modal').close(); + this.resetState(); + } else { + store.showToast(data.error || store.t('authFailed'), 'error'); + } + } catch (e) { + Alpine.store('global').showToast(e.message, 'error'); + } finally { + this.submitting = false; + } + } +}); diff --git a/public/js/components/claude-config.js b/public/js/components/claude-config.js new file mode 100755 index 0000000000000000000000000000000000000000..31f1dd728330ede47d9ff7176bac6d3ee8fa96c1 --- /dev/null +++ b/public/js/components/claude-config.js @@ -0,0 +1,508 @@ +/** + * Claude Config Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.claudeConfig = () => ({ + config: { env: {} }, + configPath: '', // Dynamic path from backend + models: [], + loading: false, + restoring: false, + gemini1mSuffix: false, + + // Mode toggle state (proxy/paid) + currentMode: 'proxy', // 'proxy' or 'paid' + modeLoading: false, + + /** + * Extract port from ANTHROPIC_BASE_URL for display + * @returns {string} Port number or '8080' as fallback + */ + getProxyPort() { + const baseUrl = this.config?.env?.ANTHROPIC_BASE_URL || ''; + try { + const url = new URL(baseUrl); + return url.port || '8080'; + } catch { + return '8080'; + } + }, + + // Presets state + presets: [], + selectedPresetName: '', + savingPreset: false, + deletingPreset: false, + pendingPresetName: '', // For unsaved changes confirmation + newPresetName: '', // For save preset modal input + + // Model fields that may contain Gemini model names + geminiModelFields: [ + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL' + ], + + init() { + // Only fetch config if this is the active sub-tab + if (this.$store.global.settingsTab === 'claude') { + this.fetchConfig(); + this.fetchPresets(); + this.fetchMode(); + } + + // Watch settings sub-tab (skip initial trigger) + this.$watch('$store.global.settingsTab', (tab, oldTab) => { + if (tab === 'claude' && oldTab !== undefined) { + this.fetchConfig(); + this.fetchPresets(); + this.fetchMode(); + } + }); + + this.$watch('$store.data.models', (val) => { + this.models = val || []; + }); + this.models = Alpine.store('data').models || []; + }, + + /** + * Detect if any Gemini model has [1m] suffix + */ + detectGemini1mSuffix() { + for (const field of this.geminiModelFields) { + const val = this.config.env[field]; + if (val && val.toLowerCase().includes('gemini') && val.includes('[1m]')) { + return true; + } + } + return false; + }, + + /** + * Toggle [1m] suffix for all Gemini models + */ + toggleGemini1mSuffix(enabled) { + for (const field of this.geminiModelFields) { + const val = this.config.env[field]; + // Fix: Case-insensitive check for gemini + if (val && /gemini/i.test(val)) { + if (enabled && !val.includes('[1m]')) { + this.config.env[field] = val.trim() + '[1m]'; + } else if (!enabled && val.includes('[1m]')) { + this.config.env[field] = val.replace(/\s*\[1m\]$/i, '').trim(); + } + } + } + this.gemini1mSuffix = enabled; + }, + + /** + * Helper to select a model from the dropdown + * @param {string} field - The config.env field to update + * @param {string} modelId - The selected model ID + */ + selectModel(field, modelId) { + if (!this.config.env) this.config.env = {}; + + let finalModelId = modelId; + // If 1M mode is enabled and it's a Gemini model, append the suffix + if (this.gemini1mSuffix && modelId.toLowerCase().includes('gemini')) { + if (!finalModelId.includes('[1m]')) { + finalModelId = finalModelId.trim() + '[1m]'; + } + } + + this.config.env[field] = finalModelId; + }, + + async fetchConfig() { + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/claude/config', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + this.config = data.config || {}; + this.configPath = data.path || '~/.claude/settings.json'; // Save dynamic path + if (!this.config.env) this.config.env = {}; + + // Default MCP CLI to true if not set + if (this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI === undefined) { + this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI = 'true'; + } + + // Detect existing [1m] suffix state, default to true + const hasExistingSuffix = this.detectGemini1mSuffix(); + const hasGeminiModels = this.geminiModelFields.some(f => + this.config.env[f]?.toLowerCase().includes('gemini') + ); + + // Default to enabled: if no suffix found but Gemini models exist, apply suffix + if (!hasExistingSuffix && hasGeminiModels) { + this.toggleGemini1mSuffix(true); + } else { + this.gemini1mSuffix = hasExistingSuffix || !hasGeminiModels; + } + } catch (e) { + console.error('Failed to fetch Claude config:', e); + } + }, + + async saveClaudeConfig() { + this.loading = true; + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/claude/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this.config) + }, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigSaved'), 'success'); + } catch (e) { + Alpine.store('global').showToast(Alpine.store('global').t('saveConfigFailed') + ': ' + e.message, 'error'); + } finally { + this.loading = false; + } + }, + + restoreDefaultClaudeConfig() { + document.getElementById('restore_defaults_modal').showModal(); + }, + + async executeRestore() { + this.restoring = true; + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/claude/config/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigRestored'), 'success'); + + // Close modal + document.getElementById('restore_defaults_modal').close(); + + // Reload the config to reflect the changes + await this.fetchConfig(); + } catch (e) { + Alpine.store('global').showToast(Alpine.store('global').t('restoreConfigFailed') + ': ' + e.message, 'error'); + } finally { + this.restoring = false; + } + }, + + // ========================================== + // Presets Management + // ========================================== + + /** + * Fetch all saved presets from the server + */ + async fetchPresets() { + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/claude/presets', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data.status === 'ok') { + this.presets = data.presets || []; + // Auto-select first preset if none selected + if (this.presets.length > 0 && !this.selectedPresetName) { + this.selectedPresetName = this.presets[0].name; + } + } + } catch (e) { + console.error('Failed to fetch presets:', e); + } + }, + + /** + * Load the selected preset into the form (does not save to Claude CLI) + */ + loadSelectedPreset() { + const preset = this.presets.find(p => p.name === this.selectedPresetName); + if (!preset) { + return; + } + + // Merge preset config into current config.env + this.config.env = { ...this.config.env, ...preset.config }; + + // Update Gemini 1M toggle based on merged config (not just preset) + this.gemini1mSuffix = this.detectGemini1mSuffix(); + + Alpine.store('global').showToast( + Alpine.store('global').t('presetLoaded') || `Preset "${preset.name}" loaded. Click "Apply to Claude CLI" to save.`, + 'success' + ); + }, + + /** + * Check if current config matches any saved preset + * @returns {boolean} True if current config matches a preset + */ + currentConfigMatchesPreset() { + const relevantKeys = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ENABLE_EXPERIMENTAL_MCP_CLI' + ]; + + for (const preset of this.presets) { + let matches = true; + for (const key of relevantKeys) { + const currentVal = this.config.env[key] || ''; + const presetVal = preset.config[key] || ''; + if (currentVal !== presetVal) { + matches = false; + break; + } + } + if (matches) return true; + } + return false; + }, + + /** + * Handle preset selection change - auto-load with unsaved changes warning + * @param {string} newPresetName - The newly selected preset name + */ + async onPresetSelect(newPresetName) { + if (!newPresetName || newPresetName === this.selectedPresetName) return; + + // Check if current config has unsaved changes (doesn't match any preset) + const hasUnsavedChanges = !this.currentConfigMatchesPreset(); + + if (hasUnsavedChanges) { + // Store pending preset and show confirmation modal + this.pendingPresetName = newPresetName; + document.getElementById('unsaved_changes_modal').showModal(); + return; + } + + this.selectedPresetName = newPresetName; + this.loadSelectedPreset(); + }, + + /** + * Confirm loading preset despite unsaved changes + */ + confirmLoadPreset() { + document.getElementById('unsaved_changes_modal').close(); + this.selectedPresetName = this.pendingPresetName; + this.pendingPresetName = ''; + this.loadSelectedPreset(); + }, + + /** + * Cancel loading preset - revert dropdown selection + */ + cancelLoadPreset() { + document.getElementById('unsaved_changes_modal').close(); + // Revert the dropdown to current selection + const select = document.querySelector('[aria-label="Select preset"]'); + if (select) select.value = this.selectedPresetName; + this.pendingPresetName = ''; + }, + + /** + * Save the current config as a new preset + */ + async saveCurrentAsPreset() { + // Clear the input and show the save preset modal + this.newPresetName = ''; + document.getElementById('save_preset_modal').showModal(); + }, + + /** + * Execute preset save after user enters name + */ + async executeSavePreset(name) { + if (!name || !name.trim()) { + Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired'), 'error'); + return; + } + + this.savingPreset = true; + const password = Alpine.store('global').webuiPassword; + + try { + // Save only relevant env vars + const relevantKeys = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ENABLE_EXPERIMENTAL_MCP_CLI' + ]; + const presetConfig = {}; + relevantKeys.forEach(k => { + if (this.config.env[k]) { + presetConfig[k] = this.config.env[k]; + } + }); + + const { response, newPassword } = await window.utils.request('/api/claude/presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: name.trim(), config: presetConfig }) + }, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data.status === 'ok') { + this.presets = data.presets || []; + this.selectedPresetName = name.trim(); + this.newPresetName = ''; // Clear the input + Alpine.store('global').showToast( + Alpine.store('global').t('presetSaved') || `Preset "${name}" saved`, + 'success' + ); + document.getElementById('save_preset_modal').close(); + } else { + throw new Error(data.error || Alpine.store('global').t('saveFailed')); + } + } catch (e) { + Alpine.store('global').showToast(Alpine.store('global').t('failedToSavePreset') + ': ' + e.message, 'error'); + } finally { + this.savingPreset = false; + } + }, + + /** + * Delete the selected preset + */ + async deleteSelectedPreset() { + if (!this.selectedPresetName) { + Alpine.store('global').showToast(Alpine.store('global').t('noPresetSelected'), 'warning'); + return; + } + + // Confirm deletion + const confirmMsg = Alpine.store('global').t('deletePresetConfirm', { name: this.selectedPresetName }); + if (!confirm(confirmMsg)) { + return; + } + + this.deletingPreset = true; + const password = Alpine.store('global').webuiPassword; + + try { + const { response, newPassword } = await window.utils.request( + `/api/claude/presets/${encodeURIComponent(this.selectedPresetName)}`, + { method: 'DELETE' }, + password + ); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data.status === 'ok') { + this.presets = data.presets || []; + // Select first available preset or clear selection + this.selectedPresetName = this.presets.length > 0 ? this.presets[0].name : ''; + Alpine.store('global').showToast( + Alpine.store('global').t('presetDeleted') || 'Preset deleted', + 'success' + ); + } else { + throw new Error(data.error || Alpine.store('global').t('deleteFailed')); + } + } catch (e) { + Alpine.store('global').showToast(Alpine.store('global').t('failedToDeletePreset') + ': ' + e.message, 'error'); + } finally { + this.deletingPreset = false; + } + }, + + // ========================================== + // Mode Toggle (Proxy/Paid) + // ========================================== + + /** + * Fetch current mode from server + */ + async fetchMode() { + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/claude/mode', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data.status === 'ok') { + this.currentMode = data.mode; + } + } catch (e) { + console.error('Failed to fetch mode:', e); + } + }, + + /** + * Toggle between proxy and paid mode + * @param {string} newMode - Target mode ('proxy' or 'paid') + */ + async toggleMode(newMode) { + if (this.modeLoading || newMode === this.currentMode) return; + + this.modeLoading = true; + const password = Alpine.store('global').webuiPassword; + + try { + const { response, newPassword } = await window.utils.request('/api/claude/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: newMode }) + }, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + + if (data.status === 'ok') { + this.currentMode = data.mode; + if (data.config) { + this.config = data.config; + if (!this.config.env) this.config.env = {}; + } + Alpine.store('global').showToast(data.message, 'success'); + + // Refresh the config and mode state + await this.fetchConfig(); + await this.fetchMode(); + } else { + throw new Error(data.error || 'Failed to switch mode'); + } + } catch (e) { + Alpine.store('global').showToast( + (Alpine.store('global').t('modeToggleFailed') || 'Failed to switch mode') + ': ' + e.message, + 'error' + ); + } finally { + this.modeLoading = false; + } + } +}); diff --git a/public/js/components/dashboard.js b/public/js/components/dashboard.js new file mode 100755 index 0000000000000000000000000000000000000000..3efa210b9755bc60d7beedfd88649653ecf64993 --- /dev/null +++ b/public/js/components/dashboard.js @@ -0,0 +1,323 @@ +/** + * Dashboard Component (Refactored) + * Orchestrates stats, charts, and filters modules + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.dashboard = () => ({ + // Core state + stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false }, + hasFilteredTrendData: true, + charts: { quotaDistribution: null, usageTrend: null }, + usageStats: { total: 0, today: 0, thisHour: 0 }, + historyData: {}, + modelTree: {}, + families: [], + + // Claude config status + claudeConfigStatus: { + needsApply: false, + presetName: '', + checked: false, + lastCheckedAt: 0 + }, + + // Filter state (from module) + ...window.DashboardFilters.getInitialState(), + + // Debounced chart update to prevent rapid successive updates + _debouncedUpdateTrendChart: null, + + init() { + // Create debounced version of updateTrendChart (300ms delay for stability) + this._debouncedUpdateTrendChart = window.utils.debounce(() => { + window.DashboardCharts.updateTrendChart(this); + }, 300); + + // Load saved preferences from localStorage + window.DashboardFilters.loadPreferences(this); + + // Check Claude config status on init + this.checkClaudeConfigStatus(); + + // Update stats when dashboard becomes active (skip initial trigger) + this.$watch('$store.global.activeTab', (val, oldVal) => { + if (val === 'dashboard' && oldVal !== undefined) { + this.$nextTick(() => { + this.updateStats(); + this.updateCharts(); + this.updateTrendChart(); + this.checkClaudeConfigStatus(); + }); + } + }); + + // Watch for data changes + this.$watch('$store.data.accounts', () => { + if (this.$store.global.activeTab === 'dashboard') { + this.updateStats(); + // Debounce chart updates to prevent rapid flickering + if (this._debouncedUpdateCharts) { + this._debouncedUpdateCharts(); + } else { + this._debouncedUpdateCharts = window.utils.debounce(() => this.updateCharts(), 100); + this._debouncedUpdateCharts(); + } + } + }); + + // Watch for history updates from data-store (automatically loaded with account data) + this.$watch('$store.data.usageHistory', (newHistory) => { + if (this.$store.global.activeTab === 'dashboard' && newHistory && Object.keys(newHistory).length > 0) { + // Optimization: Skip if data hasn't changed (prevents double render on load) + if (this.historyData && JSON.stringify(newHistory) === JSON.stringify(this.historyData)) { + return; + } + + this.historyData = newHistory; + this.processHistory(newHistory); + this.stats.hasTrendData = true; + } + }); + + // Initial update if already on dashboard + // Note: Alpine.store('data') may already have data from cache if initialized before this component + if (this.$store.global.activeTab === 'dashboard') { + this.$nextTick(() => { + this.updateStats(); + this.updateCharts(); + + // Optimization: Only process history if it hasn't been processed yet + // The usageHistory watcher above will handle updates if data changes + const history = Alpine.store('data').usageHistory; + if (history && Object.keys(history).length > 0) { + // Check if we already have this data to avoid redundant chart update + if (!this.historyData || JSON.stringify(history) !== JSON.stringify(this.historyData)) { + this.historyData = history; + this.processHistory(history); + this.stats.hasTrendData = true; + } + } + }); + } + }, + + processHistory(history) { + // Build model tree from hierarchical data + const tree = {}; + let total = 0, today = 0, thisHour = 0; + + const now = new Date(); + const todayStart = new Date(now); + todayStart.setHours(0, 0, 0, 0); + const currentHour = new Date(now); + currentHour.setMinutes(0, 0, 0); + + Object.entries(history).forEach(([iso, hourData]) => { + const timestamp = new Date(iso); + + // Process each family in the hour data + Object.entries(hourData).forEach(([key, value]) => { + // Skip metadata keys + if (key === '_total' || key === 'total') return; + + // Handle hierarchical format: { claude: { "opus-4-5": 10, "_subtotal": 10 } } + if (typeof value === 'object' && value !== null) { + if (!tree[key]) tree[key] = new Set(); + + Object.keys(value).forEach(modelName => { + if (modelName !== '_subtotal') { + tree[key].add(modelName); + } + }); + } + }); + + // Calculate totals + const hourTotal = hourData._total || hourData.total || 0; + total += hourTotal; + + if (timestamp >= todayStart) { + today += hourTotal; + } + if (timestamp.getTime() === currentHour.getTime()) { + thisHour = hourTotal; + } + }); + + this.usageStats = { total, today, thisHour }; + + // Convert Sets to sorted arrays + this.modelTree = {}; + Object.entries(tree).forEach(([family, models]) => { + this.modelTree[family] = Array.from(models).sort(); + }); + this.families = Object.keys(this.modelTree).sort(); + + // Auto-select new families/models that haven't been configured + this.autoSelectNew(); + + this.updateTrendChart(); + }, + + // Delegation methods for stats + updateStats() { + window.DashboardStats.updateStats(this); + }, + + // Delegation methods for charts + updateCharts() { + window.DashboardCharts.updateCharts(this); + }, + + updateTrendChart() { + // Use debounced version to prevent rapid successive updates + if (this._debouncedUpdateTrendChart) { + this._debouncedUpdateTrendChart(); + } else { + // Fallback if debounced version not initialized + window.DashboardCharts.updateTrendChart(this); + } + }, + + // Delegation methods for filters + loadPreferences() { + window.DashboardFilters.loadPreferences(this); + }, + + savePreferences() { + window.DashboardFilters.savePreferences(this); + }, + + setDisplayMode(mode) { + window.DashboardFilters.setDisplayMode(this, mode); + }, + + setTimeRange(range) { + window.DashboardFilters.setTimeRange(this, range); + }, + + getTimeRangeLabel() { + return window.DashboardFilters.getTimeRangeLabel(this); + }, + + toggleFamily(family) { + window.DashboardFilters.toggleFamily(this, family); + }, + + toggleModel(family, model) { + window.DashboardFilters.toggleModel(this, family, model); + }, + + isFamilySelected(family) { + return window.DashboardFilters.isFamilySelected(this, family); + }, + + isModelSelected(family, model) { + return window.DashboardFilters.isModelSelected(this, family, model); + }, + + selectAll() { + window.DashboardFilters.selectAll(this); + }, + + deselectAll() { + window.DashboardFilters.deselectAll(this); + }, + + getFamilyColor(family) { + return window.DashboardFilters.getFamilyColor(family); + }, + + getModelColor(family, modelIndex) { + return window.DashboardFilters.getModelColor(family, modelIndex); + }, + + getSelectedCount() { + return window.DashboardFilters.getSelectedCount(this); + }, + + autoSelectNew() { + window.DashboardFilters.autoSelectNew(this); + }, + + autoSelectTopN(n = 5) { + window.DashboardFilters.autoSelectTopN(this, n); + }, + + /** + * Check if Claude CLI config needs to be updated. + * Fetches both local config and presets, then compares against all presets. + * Skips if already checked within the last 30 seconds. + */ + async checkClaudeConfigStatus() { + const now = Date.now(); + if (this.claudeConfigStatus.checked && now - this.claudeConfigStatus.lastCheckedAt < 30000) return; + + try { + const password = Alpine.store('global').webuiPassword; + + const [configRes, presetsRes] = await Promise.all([ + window.utils.request('/api/claude/config', {}, password), + window.utils.request('/api/claude/presets', {}, password) + ]); + + if (!configRes.response.ok || !presetsRes.response.ok) { + this.claudeConfigStatus.checked = true; + this.claudeConfigStatus.lastCheckedAt = now; + return; + } + + const configData = await configRes.response.json(); + const presetsData = await presetsRes.response.json(); + + const localConfig = configData.config || { env: {} }; + const presets = presetsData.presets || []; + + if (presets.length === 0) { + this.claudeConfigStatus = { needsApply: false, presetName: '', checked: true, lastCheckedAt: now }; + return; + } + + const relevantKeys = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ENABLE_EXPERIMENTAL_MCP_CLI' + ]; + + // Check if local config matches ANY preset + const matchesAnyPreset = presets.some(preset => { + return relevantKeys.every(key => { + const localVal = localConfig.env?.[key] || ''; + const presetVal = preset.config?.[key] || ''; + return localVal === presetVal; + }); + }); + + this.claudeConfigStatus = { + needsApply: !matchesAnyPreset, + presetName: presets[0].name, + checked: true, + lastCheckedAt: now + }; + } catch (e) { + console.error('Failed to check Claude config status:', e); + this.claudeConfigStatus.checked = true; + this.claudeConfigStatus.lastCheckedAt = Date.now(); + } + }, + + /** + * Navigate to Claude CLI settings and dismiss the warning + */ + goToClaudeSettings() { + this.$store.global.activeTab = 'settings'; + this.$store.global.settingsTab = 'claude'; + } +}); diff --git a/public/js/components/dashboard/charts.js b/public/js/components/dashboard/charts.js new file mode 100755 index 0000000000000000000000000000000000000000..44e27c2696605ef67b529e3fdfa267e2716b9296 --- /dev/null +++ b/public/js/components/dashboard/charts.js @@ -0,0 +1,590 @@ +/** + * Dashboard Charts Module + * 职责:使用 Chart.js 渲染配额分布图和使用趋势图 + * + * 调用时机: + * - dashboard 组件 init() 时初始化图表 + * - 筛选器变化时更新图表数据 + * - $store.data 更新时刷新图表 + * + * 图表类型: + * 1. Quota Distribution(饼图):按模型家族或具体模型显示配额分布 + * 2. Usage Trend(折线图):显示历史使用趋势 + * + * 特殊处理: + * - 使用 _trendChartUpdateLock 防止并发更新导致的竞争条件 + * - 通过 debounce 优化频繁更新的性能 + * - 响应式处理:移动端自动调整图表大小和标签显示 + * + * @module DashboardCharts + */ +window.DashboardCharts = window.DashboardCharts || {}; + +// Helper to get CSS variable values (alias to window.utils.getThemeColor) +const getThemeColor = (name) => window.utils.getThemeColor(name); + +// Color palette for different families and models +const FAMILY_COLORS = { + get claude() { + return getThemeColor("--color-neon-purple"); + }, + get gemini() { + return getThemeColor("--color-neon-green"); + }, + get other() { + return getThemeColor("--color-neon-cyan"); + }, +}; + +const MODEL_COLORS = Array.from({ length: 16 }, (_, i) => + getThemeColor(`--color-chart-${i + 1}`) +); + +// Export constants for filter module +window.DashboardConstants = { FAMILY_COLORS, MODEL_COLORS }; + +// Module-level lock to prevent concurrent chart updates (fixes race condition) +let _trendChartUpdateLock = false; + +/** + * Convert hex color to rgba + * @param {string} hex - Hex color string + * @param {number} alpha - Alpha value (0-1) + * @returns {string} rgba color string + */ +window.DashboardCharts.hexToRgba = function (hex, alpha) { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (result) { + return `rgba(${parseInt(result[1], 16)}, ${parseInt( + result[2], + 16 + )}, ${parseInt(result[3], 16)}, ${alpha})`; + } + return hex; +}; + +/** + * Check if canvas is ready for Chart creation + * @param {HTMLCanvasElement} canvas - Canvas element + * @returns {boolean} True if canvas is ready + */ +function isCanvasReady(canvas) { + if (!canvas || !canvas.isConnected) return false; + if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) return false; + + try { + const ctx = canvas.getContext("2d"); + return !!ctx; + } catch (e) { + return false; + } +} + +/** + * Create a Chart.js dataset with gradient fill + * @param {string} label - Dataset label + * @param {Array} data - Data points + * @param {string} color - Line color + * @param {HTMLCanvasElement} canvas - Canvas element + * @returns {object} Chart.js dataset configuration + */ +window.DashboardCharts.createDataset = function (label, data, color, canvas) { + let gradient; + + try { + // Safely create gradient with fallback + if (canvas && canvas.getContext) { + const ctx = canvas.getContext("2d"); + if (ctx && ctx.createLinearGradient) { + gradient = ctx.createLinearGradient(0, 0, 0, 200); + gradient.addColorStop(0, window.DashboardCharts.hexToRgba(color, 0.12)); + gradient.addColorStop( + 0.6, + window.DashboardCharts.hexToRgba(color, 0.05) + ); + gradient.addColorStop(1, "rgba(0, 0, 0, 0)"); + } + } + } catch (e) { + if (window.UILogger) window.UILogger.debug("Gradient fallback:", e.message); + gradient = null; + } + + // Fallback to solid color if gradient creation failed + const backgroundColor = + gradient || window.DashboardCharts.hexToRgba(color, 0.08); + + return { + label, + data, + borderColor: color, + backgroundColor: backgroundColor, + borderWidth: 2.5, + tension: 0.35, + fill: true, + pointRadius: 2.5, + pointHoverRadius: 6, + pointBackgroundColor: color, + pointBorderColor: "rgba(9, 9, 11, 0.8)", + pointBorderWidth: 1.5, + }; +}; + +/** + * Update quota distribution donut chart + * @param {object} component - Dashboard component instance + */ +window.DashboardCharts.updateCharts = function (component) { + const canvas = document.getElementById("quotaChart"); + + // Safety checks + if (!canvas) { + console.debug("quotaChart canvas not found"); + return; + } + + // FORCE DESTROY: Check for existing chart on the canvas element property + // This handles cases where Component state is lost but DOM persists + if (canvas._chartInstance) { + console.debug("Destroying existing quota chart from canvas property"); + try { + canvas._chartInstance.destroy(); + } catch(e) { if (window.UILogger) window.UILogger.debug(e); } + canvas._chartInstance = null; + } + + // Also check component state as backup + if (component.charts.quotaDistribution) { + try { + component.charts.quotaDistribution.destroy(); + } catch(e) { } + component.charts.quotaDistribution = null; + } + + // Also try Chart.js registry + if (typeof Chart !== "undefined" && Chart.getChart) { + const regChart = Chart.getChart(canvas); + if (regChart) { + try { regChart.destroy(); } catch(e) {} + } + } + + if (typeof Chart === "undefined") { + if (window.UILogger) window.UILogger.warn("Chart.js not loaded"); + return; + } + if (!isCanvasReady(canvas)) { + if (window.UILogger) window.UILogger.debug("quotaChart canvas not ready, skipping update"); + return; + } + + // Use UNFILTERED data for global health chart + const rows = Alpine.store("data").getUnfilteredQuotaData(); + if (!rows || rows.length === 0) return; + + const healthByFamily = {}; + let totalHealthSum = 0; + let totalModelCount = 0; + + rows.forEach((row) => { + const family = row.family || "unknown"; + if (!healthByFamily[family]) { + healthByFamily[family] = { total: 0, weighted: 0 }; + } + + // Calculate average health from quotaInfo (each entry has { pct }) + // Health = average of all account quotas for this model + const quotaInfo = row.quotaInfo || []; + let avgHealth = 0; + + if (quotaInfo.length > 0) { + avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length; + } + // If quotaInfo is empty, avgHealth remains 0 (depleted/unknown) + + healthByFamily[family].total++; + healthByFamily[family].weighted += avgHealth; + totalHealthSum += avgHealth; + totalModelCount++; + }); + + // Update overall health for dashboard display + component.stats.overallHealth = totalModelCount > 0 + ? Math.round(totalHealthSum / totalModelCount) + : 0; + + const familyColors = { + claude: getThemeColor("--color-neon-purple") || "#a855f7", + gemini: getThemeColor("--color-neon-green") || "#22c55e", + unknown: getThemeColor("--color-neon-cyan") || "#06b6d4", + }; + + const data = []; + const colors = []; + const labels = []; + + const totalFamilies = Object.keys(healthByFamily).length; + const segmentSize = 100 / totalFamilies; + + Object.entries(healthByFamily).forEach(([family, { total, weighted }]) => { + const health = weighted / total; + const activeVal = (health / 100) * segmentSize; + const inactiveVal = segmentSize - activeVal; + + const familyColor = familyColors[family] || familyColors["unknown"]; + + // Get translation keys + const store = Alpine.store("global"); + const familyKey = + "family" + family.charAt(0).toUpperCase() + family.slice(1); + const familyName = store.t(familyKey); + + // Labels using translations if possible + const activeLabel = + family === "claude" + ? store.t("claudeActive") + : family === "gemini" + ? store.t("geminiActive") + : `${familyName} ${store.t("activeSuffix")}`; + + const depletedLabel = + family === "claude" + ? store.t("claudeEmpty") + : family === "gemini" + ? store.t("geminiEmpty") + : `${familyName} ${store.t("depleted")}`; + + // Active segment + data.push(activeVal); + colors.push(familyColor); + labels.push(activeLabel); + + // Inactive segment + data.push(inactiveVal); + // Use higher opacity (0.6) to ensure the ring color matches the legend more closely + // while still differentiating "depleted" from "active" (1.0 opacity) + colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.6)); + labels.push(depletedLabel); + }); + + // Create Chart + try { + const newChart = new Chart(canvas, { + // ... config + type: "doughnut", + data: { + labels: labels, + datasets: [ + { + data: data, + backgroundColor: colors, + borderColor: getThemeColor("--color-space-950"), + borderWidth: 0, + hoverOffset: 0, + borderRadius: 0, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + cutout: "85%", + rotation: -90, + circumference: 360, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + title: { display: false }, + }, + animation: { + // Disable animation for quota chart to prevent "double refresh" visual glitch + duration: 0 + }, + }, + }); + + // SAVE INSTANCE TO CANVAS AND COMPONENT + canvas._chartInstance = newChart; + component.charts.quotaDistribution = newChart; + + } catch (e) { + console.error("Failed to create quota chart:", e); + } +}; + +/** + * Update usage trend line chart + * @param {object} component - Dashboard component instance + */ +window.DashboardCharts.updateTrendChart = function (component) { + // Prevent concurrent updates (fixes race condition on rapid toggling) + if (_trendChartUpdateLock) { + if (window.UILogger) window.UILogger.debug("[updateTrendChart] Update already in progress, skipping"); + return; + } + _trendChartUpdateLock = true; + + const logger = window.UILogger || console; + logger.debug("[updateTrendChart] Starting update..."); + + const canvas = document.getElementById("usageTrendChart"); + + // FORCE DESTROY: Check for existing chart on the canvas element property + if (canvas) { + if (canvas._chartInstance) { + console.debug("Destroying existing trend chart from canvas property"); + try { + canvas._chartInstance.stop(); + canvas._chartInstance.destroy(); + } catch(e) { if (window.UILogger) window.UILogger.debug(e); } + canvas._chartInstance = null; + } + + // Also try Chart.js registry + if (typeof Chart !== "undefined" && Chart.getChart) { + const regChart = Chart.getChart(canvas); + if (regChart) { + try { regChart.stop(); regChart.destroy(); } catch(e) {} + } + } + } + + // Also check component state + if (component.charts.usageTrend) { + try { + component.charts.usageTrend.stop(); + component.charts.usageTrend.destroy(); + } catch (e) { } + component.charts.usageTrend = null; + } + + // Safety checks + if (!canvas) { + if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not found in DOM"); + _trendChartUpdateLock = false; + return; + } + if (typeof Chart === "undefined") { + if (window.UILogger) window.UILogger.warn("[updateTrendChart] Chart.js not loaded"); + _trendChartUpdateLock = false; + return; + } + + if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas element:", { + exists: !!canvas, + isConnected: canvas.isConnected, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + parentElement: canvas.parentElement?.tagName, + }); + + if (!isCanvasReady(canvas)) { + if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not ready", { + isConnected: canvas.isConnected, + width: canvas.offsetWidth, + height: canvas.offsetHeight, + }); + _trendChartUpdateLock = false; + return; + } + + // Clear canvas to ensure clean state after destroy + try { + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + } catch (e) { + if (window.UILogger) window.UILogger.debug("[updateTrendChart] Failed to clear canvas:", e.message); + } + + if (window.UILogger) window.UILogger.debug( + "[updateTrendChart] Canvas is ready, proceeding with chart creation" + ); + + // Use filtered history data based on time range + const history = window.DashboardFilters.getFilteredHistoryData(component); + if (!history || Object.keys(history).length === 0) { + if (window.UILogger) window.UILogger.debug("No history data available for trend chart (after filtering)"); + component.hasFilteredTrendData = false; + _trendChartUpdateLock = false; + return; + } + + component.hasFilteredTrendData = true; + + // Sort entries by timestamp for correct order + const sortedEntries = Object.entries(history).sort( + ([a], [b]) => new Date(a).getTime() - new Date(b).getTime() + ); + + // Determine if data spans multiple days (for smart label formatting) + const timestamps = sortedEntries.map(([iso]) => new Date(iso)); + const isMultiDay = timestamps.length > 1 && + timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString(); + + // Helper to format X-axis labels based on time range and multi-day status + const formatLabel = (date) => { + const timeRange = component.timeRange || '24h'; + + if (timeRange === '7d') { + // Week view: show MM/DD + return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }); + } else if (isMultiDay || timeRange === 'all') { + // Multi-day data: show MM/DD HH:MM + return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }) + ' ' + + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + // Same day: show HH:MM only + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + }; + + const labels = []; + const datasets = []; + + if (component.displayMode === "family") { + // Aggregate by family + const dataByFamily = {}; + component.selectedFamilies.forEach((family) => { + dataByFamily[family] = []; + }); + + sortedEntries.forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push(formatLabel(date)); + + component.selectedFamilies.forEach((family) => { + const familyData = hourData[family]; + const count = familyData?._subtotal || 0; + dataByFamily[family].push(count); + }); + }); + + // Build datasets for families + component.selectedFamilies.forEach((family) => { + const color = window.DashboardFilters.getFamilyColor(family); + const familyKey = + "family" + family.charAt(0).toUpperCase() + family.slice(1); + const label = Alpine.store("global").t(familyKey); + datasets.push( + window.DashboardCharts.createDataset( + label, + dataByFamily[family], + color, + canvas + ) + ); + }); + } else { + // Show individual models + const dataByModel = {}; + + // Initialize data arrays + component.families.forEach((family) => { + (component.selectedModels[family] || []).forEach((model) => { + const key = `${family}:${model}`; + dataByModel[key] = []; + }); + }); + + sortedEntries.forEach(([iso, hourData]) => { + const date = new Date(iso); + labels.push(formatLabel(date)); + + component.families.forEach((family) => { + const familyData = hourData[family] || {}; + (component.selectedModels[family] || []).forEach((model) => { + const key = `${family}:${model}`; + dataByModel[key].push(familyData[model] || 0); + }); + }); + }); + + // Build datasets for models + component.families.forEach((family) => { + (component.selectedModels[family] || []).forEach((model, modelIndex) => { + const key = `${family}:${model}`; + const color = window.DashboardFilters.getModelColor(family, modelIndex); + datasets.push( + window.DashboardCharts.createDataset( + model, + dataByModel[key], + color, + canvas + ) + ); + }); + }); + } + + try { + const newChart = new Chart(canvas, { + type: "line", + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: 300, // Reduced animation for faster updates + }, + interaction: { + mode: "index", + intersect: false, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: + getThemeColor("--color-space-950") || "rgba(24, 24, 27, 0.9)", + titleColor: getThemeColor("--color-text-main"), + bodyColor: getThemeColor("--color-text-bright"), + borderColor: getThemeColor("--color-space-border"), + borderWidth: 1, + padding: 10, + displayColors: true, + callbacks: { + label: function (context) { + return context.dataset.label + ": " + context.parsed.y; + }, + }, + }, + }, + scales: { + x: { + display: true, + grid: { display: false }, + ticks: { + color: getThemeColor("--color-text-muted"), + font: { size: 10 }, + }, + }, + y: { + display: true, + beginAtZero: true, + grid: { + display: true, + color: + getThemeColor("--color-space-border") + "1a" || + "rgba(255,255,255,0.05)", + }, + ticks: { + color: getThemeColor("--color-text-muted"), + font: { size: 10 }, + }, + }, + }, + }, + }); + + // SAVE INSTANCE + canvas._chartInstance = newChart; + component.charts.usageTrend = newChart; + + } catch (e) { + console.error("Failed to create trend chart:", e); + } finally { + // Always release lock + _trendChartUpdateLock = false; + } +}; diff --git a/public/js/components/dashboard/filters.js b/public/js/components/dashboard/filters.js new file mode 100755 index 0000000000000000000000000000000000000000..1ed31d8e036bab02852f0b39c50bd2333c5e0076 --- /dev/null +++ b/public/js/components/dashboard/filters.js @@ -0,0 +1,362 @@ +/** + * Dashboard Filters Module + * 职责:管理图表筛选器的状态和持久化 + * + * 功能: + * 1. 时间范围筛选(1h/6h/24h/7d/all) + * 2. 显示模式切换(按家族/按模型) + * 3. 模型/家族多选筛选 + * 4. 筛选器状态持久化到 localStorage + * + * 调用时机: + * - 组件初始化时加载用户偏好 + * - 筛选器变化时保存并触发图表更新 + * + * 持久化键: + * - localStorage['dashboard_chart_prefs'] + * + * @module DashboardFilters + */ +window.DashboardFilters = window.DashboardFilters || {}; + +/** + * Get initial filter state + * @returns {object} Initial state for filter properties + */ +window.DashboardFilters.getInitialState = function() { + return { + timeRange: '24h', // '1h', '6h', '24h', '7d', 'all' + displayMode: 'model', + selectedFamilies: [], + selectedModels: {}, + showModelFilter: false, + showTimeRangeDropdown: false, + showDisplayModeDropdown: false + }; +}; + +/** + * Load filter preferences from localStorage + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.loadPreferences = function(component) { + try { + const saved = localStorage.getItem('dashboard_chart_prefs'); + if (saved) { + const prefs = JSON.parse(saved); + component.timeRange = prefs.timeRange || '24h'; + component.displayMode = prefs.displayMode || 'model'; + component.selectedFamilies = prefs.selectedFamilies || []; + component.selectedModels = prefs.selectedModels || {}; + } + } catch (e) { + if (window.UILogger) window.UILogger.debug('Failed to load dashboard preferences:', e.message); + } +}; + +/** + * Save filter preferences to localStorage + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.savePreferences = function(component) { + try { + localStorage.setItem('dashboard_chart_prefs', JSON.stringify({ + timeRange: component.timeRange, + displayMode: component.displayMode, + selectedFamilies: component.selectedFamilies, + selectedModels: component.selectedModels + })); + } catch (e) { + if (window.UILogger) window.UILogger.debug('Failed to save dashboard preferences:', e.message); + } +}; + +/** + * Set display mode (family or model) + * @param {object} component - Dashboard component instance + * @param {string} mode - 'family' or 'model' + */ +window.DashboardFilters.setDisplayMode = function(component, mode) { + component.displayMode = mode; + component.showDisplayModeDropdown = false; + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Set time range filter + * @param {object} component - Dashboard component instance + * @param {string} range - '1h', '6h', '24h', '7d', 'all' + */ +window.DashboardFilters.setTimeRange = function(component, range) { + component.timeRange = range; + component.showTimeRangeDropdown = false; + window.DashboardFilters.savePreferences(component); + component.updateTrendChart(); +}; + +/** + * Get time range cutoff timestamp + * @param {string} range - Time range code + * @returns {number|null} Cutoff timestamp or null for 'all' + */ +window.DashboardFilters.getTimeRangeCutoff = function(range) { + const now = Date.now(); + switch (range) { + case '1h': return now - 1 * 60 * 60 * 1000; + case '6h': return now - 6 * 60 * 60 * 1000; + case '24h': return now - 24 * 60 * 60 * 1000; + case '7d': return now - 7 * 24 * 60 * 60 * 1000; + default: return null; // 'all' + } +}; + +/** + * Get filtered history data based on time range + * @param {object} component - Dashboard component instance + * @returns {object} Filtered history data + */ +window.DashboardFilters.getFilteredHistoryData = function(component) { + const history = component.historyData; + if (!history || Object.keys(history).length === 0) return {}; + + const cutoff = window.DashboardFilters.getTimeRangeCutoff(component.timeRange); + if (!cutoff) return history; // 'all' - return everything + + const filtered = {}; + Object.entries(history).forEach(([iso, data]) => { + const timestamp = new Date(iso).getTime(); + if (timestamp >= cutoff) { + filtered[iso] = data; + } + }); + return filtered; +}; + +/** + * Get time range label for display + * @param {object} component - Dashboard component instance + * @returns {string} Translated label + */ +window.DashboardFilters.getTimeRangeLabel = function(component) { + const store = Alpine.store('global'); + switch (component.timeRange) { + case '1h': return store.t('last1Hour'); + case '6h': return store.t('last6Hours'); + case '24h': return store.t('last24Hours'); + case '7d': return store.t('last7Days'); + default: return store.t('allTime'); + } +}; + +/** + * Toggle family selection + * @param {object} component - Dashboard component instance + * @param {string} family - Family name (e.g., 'claude', 'gemini') + */ +window.DashboardFilters.toggleFamily = function(component, family) { + const index = component.selectedFamilies.indexOf(family); + if (index > -1) { + component.selectedFamilies.splice(index, 1); + } else { + component.selectedFamilies.push(family); + } + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Toggle model selection within a family + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @param {string} model - Model name + */ +window.DashboardFilters.toggleModel = function(component, family, model) { + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + const index = component.selectedModels[family].indexOf(model); + if (index > -1) { + component.selectedModels[family].splice(index, 1); + } else { + component.selectedModels[family].push(model); + } + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Check if family is selected + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @returns {boolean} + */ +window.DashboardFilters.isFamilySelected = function(component, family) { + return component.selectedFamilies.includes(family); +}; + +/** + * Check if model is selected + * @param {object} component - Dashboard component instance + * @param {string} family - Family name + * @param {string} model - Model name + * @returns {boolean} + */ +window.DashboardFilters.isModelSelected = function(component, family, model) { + return component.selectedModels[family]?.includes(model) || false; +}; + +/** + * Select all families and models + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.selectAll = function(component) { + component.selectedFamilies = [...component.families]; + component.families.forEach(family => { + component.selectedModels[family] = [...(component.modelTree[family] || [])]; + }); + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Deselect all families and models + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.deselectAll = function(component) { + component.selectedFamilies = []; + component.selectedModels = {}; + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; + +/** + * Get color for a family + * @param {string} family - Family name + * @returns {string} Color value + */ +window.DashboardFilters.getFamilyColor = function(family) { + const FAMILY_COLORS = window.DashboardConstants?.FAMILY_COLORS || {}; + return FAMILY_COLORS[family] || FAMILY_COLORS.other; +}; + +/** + * Get color for a model (with index for variation within family) + * @param {string} family - Family name + * @param {number} modelIndex - Index of model within family + * @returns {string} Color value + */ +window.DashboardFilters.getModelColor = function(family, modelIndex) { + const MODEL_COLORS = window.DashboardConstants?.MODEL_COLORS || []; + const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8); + return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length]; +}; + +/** + * Get count of selected items for display + * @param {object} component - Dashboard component instance + * @returns {string} Selected count string (e.g., "3/5") + */ +window.DashboardFilters.getSelectedCount = function(component) { + if (component.displayMode === 'family') { + return `${component.selectedFamilies.length}/${component.families.length}`; + } + let selected = 0, total = 0; + component.families.forEach(family => { + const models = component.modelTree[family] || []; + total += models.length; + selected += (component.selectedModels[family] || []).length; + }); + return `${selected}/${total}`; +}; + +/** + * Auto-select new families/models that haven't been configured + * @param {object} component - Dashboard component instance + */ +window.DashboardFilters.autoSelectNew = function(component) { + // If no preferences saved, select all + if (component.selectedFamilies.length === 0 && Object.keys(component.selectedModels).length === 0) { + component.selectedFamilies = [...component.families]; + component.families.forEach(family => { + component.selectedModels[family] = [...(component.modelTree[family] || [])]; + }); + window.DashboardFilters.savePreferences(component); + return; + } + + // Add new families/models that appeared + component.families.forEach(family => { + if (!component.selectedFamilies.includes(family)) { + component.selectedFamilies.push(family); + } + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + (component.modelTree[family] || []).forEach(model => { + if (!component.selectedModels[family].includes(model)) { + component.selectedModels[family].push(model); + } + }); + }); +}; + +/** + * Auto-select top N models by usage (past 24 hours) + * @param {object} component - Dashboard component instance + * @param {number} n - Number of models to select (default: 5) + */ +window.DashboardFilters.autoSelectTopN = function(component, n = 5) { + // Calculate usage for each model over past 24 hours + const usage = {}; + const now = Date.now(); + const dayAgo = now - 24 * 60 * 60 * 1000; + + Object.entries(component.historyData).forEach(([iso, hourData]) => { + const timestamp = new Date(iso).getTime(); + if (timestamp < dayAgo) return; + + Object.entries(hourData).forEach(([family, familyData]) => { + if (typeof familyData === 'object' && family !== '_total') { + Object.entries(familyData).forEach(([model, count]) => { + if (model !== '_subtotal') { + const key = `${family}:${model}`; + usage[key] = (usage[key] || 0) + count; + } + }); + } + }); + }); + + // Sort by usage and take top N + const sorted = Object.entries(usage) + .sort((a, b) => b[1] - a[1]) + .slice(0, n); + + // Clear current selection + component.selectedFamilies = []; + component.selectedModels = {}; + + // Select top models and their families + sorted.forEach(([key, _]) => { + const [family, model] = key.split(':'); + if (!component.selectedFamilies.includes(family)) { + component.selectedFamilies.push(family); + } + if (!component.selectedModels[family]) { + component.selectedModels[family] = []; + } + if (!component.selectedModels[family].includes(model)) { + component.selectedModels[family].push(model); + } + }); + + window.DashboardFilters.savePreferences(component); + // updateTrendChart uses debounce internally, call directly + component.updateTrendChart(); +}; diff --git a/public/js/components/dashboard/stats.js b/public/js/components/dashboard/stats.js new file mode 100755 index 0000000000000000000000000000000000000000..bb9ac9613237cb75fa1b370d970e962e0f430e2a --- /dev/null +++ b/public/js/components/dashboard/stats.js @@ -0,0 +1,110 @@ +/** + * Dashboard Stats Module + * 职责:根据 Alpine.store('data') 计算账号统计数据 + * + * 调用时机: + * - dashboard 组件 init() 时 + * - $store.data 更新时(通过 $watch 监听) + * + * 统计维度: + * - total: 启用账号总数(排除禁用账号) + * - active: 有可用配额的账号数 + * - limited: 配额受限或失效的账号数 + * - subscription: 按订阅级别分类(ultra/pro/free) + * + * @module DashboardStats + */ +window.DashboardStats = window.DashboardStats || {}; + +/** + * 更新账号统计数据 + * + * 统计逻辑: + * 1. 仅统计启用的账号(enabled !== false) + * 2. 检查账号下所有追踪模型的配额 + * 3. 如果任一追踪模型配额 <= 5%,则标记为 limited (Rate Limited Cooldown) + * 4. 如果所有追踪模型配额 > 5%,则标记为 active + * 5. 状态非 'ok' 的账号归为 limited + * + * @param {object} component - Dashboard 组件实例(Alpine.js 上下文) + * @param {object} component.stats - 统计数据对象(会被修改) + * @param {number} component.stats.total - 启用账号总数 + * @param {number} component.stats.active - 活跃账号数 + * @param {number} component.stats.limited - 受限账号数 + * @param {object} component.stats.subscription - 订阅级别分布 + * @returns {void} + */ +window.DashboardStats.updateStats = function(component) { + const accounts = Alpine.store('data').accounts; + let active = 0, limited = 0; + + // Only count enabled accounts in statistics + const enabledAccounts = accounts.filter(acc => acc.enabled !== false); + + enabledAccounts.forEach(acc => { + if (acc.status === 'ok') { + const limits = Object.entries(acc.limits || {}); + + if (limits.length === 0) { + // No limit data available, consider limited to be safe + limited++; + return; + } + + // Check if ANY tracked model is rate limited (<= 5%) + // We consider all models in the limits object as "tracked" + const hasRateLimitedModel = limits.some(([_, l]) => { + // Treat null/undefined fraction as 0 (limited) + if (!l || l.remainingFraction === null || l.remainingFraction === undefined) return true; + return l.remainingFraction <= 0.05; + }); + + if (hasRateLimitedModel) { + limited++; + } else { + active++; + } + } else { + limited++; + } + }); + + // TOTAL shows only enabled accounts + // Disabled accounts are excluded from all statistics + component.stats.total = enabledAccounts.length; + component.stats.active = active; + component.stats.limited = limited; + + // Calculate model usage for rate limit details + let totalLimitedModels = 0; + let totalTrackedModels = 0; + + enabledAccounts.forEach(acc => { + const limits = Object.entries(acc.limits || {}); + limits.forEach(([id, l]) => { + totalTrackedModels++; + if (!l || l.remainingFraction == null || l.remainingFraction <= 0.05) { + totalLimitedModels++; + } + }); + }); + + component.stats.modelUsage = { + limited: totalLimitedModels, + total: totalTrackedModels + }; + + // Calculate subscription tier distribution + const subscription = { ultra: 0, pro: 0, free: 0 }; + enabledAccounts.forEach(acc => { + const tier = acc.subscription?.tier || 'free'; + if (tier === 'ultra') { + subscription.ultra++; + } else if (tier === 'pro') { + subscription.pro++; + } else { + subscription.free++; + } + }); + component.stats.subscription = subscription; +}; diff --git a/public/js/components/logs-viewer.js b/public/js/components/logs-viewer.js new file mode 100755 index 0000000000000000000000000000000000000000..4f1159ce7ccf33f260392c9d00a2dd20b2867102 --- /dev/null +++ b/public/js/components/logs-viewer.js @@ -0,0 +1,131 @@ +/** + * Logs Viewer Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.logsViewer = () => ({ + logs: [], + isAutoScroll: true, + eventSource: null, + searchQuery: '', + filters: { + INFO: true, + WARN: true, + ERROR: true, + SUCCESS: true, + DEBUG: false + }, + + get filteredLogs() { + const query = this.searchQuery.trim(); + if (!query) { + return this.logs.filter(log => this.filters[log.level]); + } + + // Try regex first, fallback to plain text search + let matcher; + try { + const regex = new RegExp(query, 'i'); + matcher = (msg) => regex.test(msg); + } catch (e) { + // Invalid regex, fallback to case-insensitive string search + const lowerQuery = query.toLowerCase(); + matcher = (msg) => msg.toLowerCase().includes(lowerQuery); + } + + return this.logs.filter(log => { + // Level Filter + if (!this.filters[log.level]) return false; + + // Search Filter + return matcher(log.message); + }); + }, + + init() { + this.startLogStream(); + + // Sync DEBUG filter with debugLogging sub-toggle + const settings = Alpine.store('settings'); + if (settings) { + this.filters.DEBUG = !!settings.debugLogging; + this.$watch('$store.settings.debugLogging', (val) => { + this.filters.DEBUG = !!val; + }); + } + + this.$watch('isAutoScroll', (val) => { + if (val) this.scrollToBottom(); + }); + + // Watch filters to maintain auto-scroll if enabled + this.$watch('searchQuery', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) }); + this.$watch('filters', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) }); + }, + + startLogStream() { + if (this.eventSource) this.eventSource.close(); + + const password = Alpine.store('global').webuiPassword; + const url = password + ? `/api/logs/stream?history=true&password=${encodeURIComponent(password)}` + : '/api/logs/stream?history=true'; + + this.eventSource = new EventSource(url); + this.eventSource.onmessage = (event) => { + try { + const log = JSON.parse(event.data); + this.logs.push(log); + + // Limit log buffer + const limit = Alpine.store('settings')?.logLimit || window.AppConstants.LIMITS.DEFAULT_LOG_LIMIT; + if (this.logs.length > limit) { + this.logs = this.logs.slice(-limit); + } + + if (this.isAutoScroll) { + this.$nextTick(() => this.scrollToBottom()); + } + } catch (e) { + if (window.UILogger) window.UILogger.debug('Log parse error:', e.message); + } + }; + + this.eventSource.onerror = () => { + if (window.UILogger) window.UILogger.debug('Log stream disconnected, reconnecting...'); + setTimeout(() => this.startLogStream(), 3000); + }; + }, + + scrollToBottom() { + const container = document.getElementById('logs-container'); + if (container) container.scrollTop = container.scrollHeight; + }, + + clearLogs() { + this.logs = []; + }, + + exportLogs() { + if (this.logs.length === 0) return; + + const shouldRedact = Alpine.store('settings')?.redactMode && window.Redact; + const lines = this.logs.map(log => { + const ts = new Date(log.timestamp).toISOString(); + const message = shouldRedact ? window.Redact.logMessage(log.message) : log.message; + return `[${ts}] [${log.level}] ${message}`; + }); + + const text = lines.join('\n'); + const blob = new Blob([text], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `proxy-logs-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } +}); diff --git a/public/js/components/model-dropdown.js b/public/js/components/model-dropdown.js new file mode 100755 index 0000000000000000000000000000000000000000..5193a3de00bc5d67555f72b0f7feb6edf2cff9e3 --- /dev/null +++ b/public/js/components/model-dropdown.js @@ -0,0 +1,151 @@ +/** + * Model Dropdown Component + * Reusable grouped model selector with search, clear, and keyboard navigation. + * Registers itself to window.Components for Alpine.js to consume. + * + * Usage (inside x-data="claudeConfig"): + * x-data="window.Components.modelDropdown('ANTHROPIC_MODEL', 'primaryModel', 'cyan')" + * + * @param {string} field - config.env key (e.g. 'ANTHROPIC_MODEL') + * @param {string} labelKey - i18n key for the label + * @param {string} accentColor - 'cyan' or 'purple' (maps to Tailwind border classes) + * + * Requires parent scope to provide: config, selectModel(), gemini1mSuffix + */ +window.Components = window.Components || {}; + +window.Components.modelDropdown = (field, labelKey, accentColor) => ({ + field, + labelKey, + accentColor, + + open: false, + searchTerm: '', + highlightIndex: -1, + + init() { + if (typeof this.selectModel !== 'function' || !this.config) { + console.error(`modelDropdown(${field}): must be nested inside a parent scope that provides config and selectModel()`); + } + }, + + get currentValue() { + return this.config?.env?.[this.field] || ''; + }, + + get filteredModels() { + const models = this.$store.data.models || []; + if (!this.searchTerm) return models; + const term = this.searchTerm.toLowerCase(); + return models.filter(m => m.toLowerCase().includes(term)); + }, + + get groupedModels() { + const groups = [ + { family: 'claude', label: this.$store.global.t('familyClaude'), items: [] }, + { family: 'gemini', label: this.$store.global.t('familyGemini'), items: [] }, + { family: 'other', label: this.$store.global.t('familyOther'), items: [] } + ]; + for (const modelId of this.filteredModels) { + const fam = this.$store.data.getModelFamily(modelId); + const group = groups.find(g => g.family === fam) || groups[2]; + group.items.push(modelId); + } + return groups.filter(g => g.items.length > 0); + }, + + get flatItems() { + const items = []; + if (this.currentValue) { + items.push({ type: 'clear' }); + } + for (const group of this.groupedModels) { + for (const modelId of group.items) { + items.push({ type: 'model', id: modelId }); + } + } + return items; + }, + + get focusBorderClass() { + return this.accentColor === 'purple' + ? 'focus:!border-neon-purple' + : 'focus:!border-neon-cyan'; + }, + + openDropdown() { + this.open = true; + this.searchTerm = ''; + this.highlightIndex = -1; + }, + + closeDropdown() { + this.open = false; + this.searchTerm = ''; + this.highlightIndex = -1; + }, + + choose(modelId) { + this.selectModel(this.field, modelId); + this.closeDropdown(); + }, + + clearField() { + if (this.config?.env) { + delete this.config.env[this.field]; + } + this.closeDropdown(); + }, + + isSelected(modelId) { + const val = this.currentValue; + return val === modelId || val === modelId + '[1m]'; + }, + + onKeydown(event) { + if (!this.open) { + if (event.key === 'ArrowDown' || event.key === 'Enter') { + event.preventDefault(); + this.openDropdown(); + } + return; + } + + const items = this.flatItems; + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.highlightIndex = Math.min(this.highlightIndex + 1, items.length - 1); + this.scrollToHighlighted(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.highlightIndex = Math.max(this.highlightIndex - 1, 0); + this.scrollToHighlighted(); + } else if (event.key === 'Enter') { + event.preventDefault(); + if (this.highlightIndex >= 0 && this.highlightIndex < items.length) { + const item = items[this.highlightIndex]; + if (item.type === 'clear') { + this.clearField(); + } else { + this.choose(item.id); + } + } + } else if (event.key === 'Escape') { + event.preventDefault(); + this.closeDropdown(); + } + }, + + scrollToHighlighted() { + this.$nextTick(() => { + const list = this.$refs.dropdownList; + if (!list) return; + const el = list.querySelector('[data-highlight="true"]'); + if (el) el.scrollIntoView({ block: 'nearest' }); + }); + }, + + getFlatIndex(modelId) { + return this.flatItems.findIndex(i => i.type === 'model' && i.id === modelId); + } +}); diff --git a/public/js/components/models.js b/public/js/components/models.js new file mode 100755 index 0000000000000000000000000000000000000000..37c843aac2c0db8ed707ebc8a69d9d464d284462 --- /dev/null +++ b/public/js/components/models.js @@ -0,0 +1,301 @@ +/** + * Models Component + * Displays model quota/status list + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.models = () => ({ + // Color palette for per-account threshold markers + thresholdColors: [ + { bg: '#eab308', shadow: 'rgba(234,179,8,0.5)' }, // yellow + { bg: '#06b6d4', shadow: 'rgba(6,182,212,0.5)' }, // cyan + { bg: '#a855f7', shadow: 'rgba(168,85,247,0.5)' }, // purple + { bg: '#22c55e', shadow: 'rgba(34,197,94,0.5)' }, // green + { bg: '#ef4444', shadow: 'rgba(239,68,68,0.5)' }, // red + { bg: '#f97316', shadow: 'rgba(249,115,22,0.5)' }, // orange + { bg: '#ec4899', shadow: 'rgba(236,72,153,0.5)' }, // pink + { bg: '#8b5cf6', shadow: 'rgba(139,92,246,0.5)' }, // violet + ], + + getThresholdColor(index) { + return this.thresholdColors[index % this.thresholdColors.length]; + }, + + // Drag state for threshold markers + dragging: { + active: false, + email: null, + modelId: null, + barRect: null, + currentPct: 0, + originalPct: 0 + }, + + // Tracks which model rows have their per-account breakdown expanded beyond the cap + expandedModels: new Set(), + + isExpanded(modelId) { + return this.expandedModels.has(modelId); + }, + + toggleExpanded(modelId) { + if (this.expandedModels.has(modelId)) { + this.expandedModels.delete(modelId); + } else { + this.expandedModels.add(modelId); + } + // Force Alpine reactivity + this.expandedModels = new Set(this.expandedModels); + }, + + /** + * Get visible account rows for a model's breakdown, respecting the cap + */ + getVisibleAccounts(row) { + const all = row.quotaInfo || []; + if (Alpine.store('settings').showAllAccounts || this.isExpanded(row.modelId)) { + return all; + } + const limit = window.AppConstants.LIMITS.ACCOUNT_BREAKDOWN_LIMIT; + return all.slice(0, limit); + }, + + /** + * Get the number of hidden accounts for a model row + */ + getHiddenCount(row) { + const all = row.quotaInfo || []; + const limit = window.AppConstants.LIMITS.ACCOUNT_BREAKDOWN_LIMIT; + if (Alpine.store('settings').showAllAccounts || this.isExpanded(row.modelId)) { + return 0; + } + return Math.max(0, all.length - limit); + }, + + // Model editing state (from main) + editingModelId: null, + newMapping: '', + + isEditing(modelId) { + return this.editingModelId === modelId; + }, + + startEditing(modelId) { + this.editingModelId = modelId; + }, + + stopEditing() { + this.editingModelId = null; + }, + + /** + * Start dragging a threshold marker + */ + startDrag(event, q, row) { + // Find the progress bar element (closest .relative container) + const markerEl = event.currentTarget; + const barContainer = markerEl.parentElement; + const barRect = barContainer.getBoundingClientRect(); + + this.dragging = { + active: true, + email: q.fullEmail, + modelId: row.modelId, + barRect, + currentPct: q.thresholdPct, + originalPct: q.thresholdPct + }; + + // Prevent text selection while dragging + document.body.classList.add('select-none'); + + // Bind document-level listeners for smooth dragging outside the marker + this._onDrag = (e) => this.onDrag(e); + this._endDrag = () => this.endDrag(); + document.addEventListener('mousemove', this._onDrag); + document.addEventListener('mouseup', this._endDrag); + document.addEventListener('touchmove', this._onDrag, { passive: false }); + document.addEventListener('touchend', this._endDrag); + }, + + /** + * Handle drag movement — compute percentage from mouse position + */ + onDrag(event) { + if (!this.dragging.active) return; + event.preventDefault(); + + const clientX = event.touches ? event.touches[0].clientX : event.clientX; + const { left, width } = this.dragging.barRect; + let pct = Math.round((clientX - left) / width * 100); + pct = Math.max(0, Math.min(99, pct)); + + this.dragging.currentPct = pct; + }, + + /** + * End drag — save the new threshold value + */ + endDrag() { + if (!this.dragging.active) return; + + // Clean up listeners + document.removeEventListener('mousemove', this._onDrag); + document.removeEventListener('mouseup', this._endDrag); + document.removeEventListener('touchmove', this._onDrag); + document.removeEventListener('touchend', this._endDrag); + document.body.classList.remove('select-none'); + + const { email, modelId, currentPct, originalPct } = this.dragging; + + // Only save if value actually changed + if (currentPct !== originalPct) { + // Optimistic in-place update: mutate existing quotaInfo entries directly + // to avoid full DOM rebuild from computeQuotaRows() + const dataStore = Alpine.store('data'); + const account = dataStore.accounts.find(a => a.email === email); + if (account) { + if (!account.modelQuotaThresholds) account.modelQuotaThresholds = {}; + if (currentPct === 0) { + delete account.modelQuotaThresholds[modelId]; + } else { + account.modelQuotaThresholds[modelId] = currentPct / 100; + } + } + // Patch quotaRows in-place so Alpine updates without tearing down DOM + const rows = dataStore.quotaRows || []; + for (const row of rows) { + if (row.modelId !== modelId) continue; + for (const q of row.quotaInfo) { + if (q.fullEmail !== email) continue; + q.thresholdPct = currentPct; + } + // Recompute row-level threshold stats + const activePcts = row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0); + row.effectiveThresholdPct = activePcts.length > 0 ? Math.max(...activePcts) : 0; + row.hasVariedThresholds = new Set(activePcts).size > 1; + } + this.dragging.active = false; + this.saveModelThreshold(email, modelId, currentPct); + } else { + this.dragging.active = false; + } + }, + + /** + * Save a per-model threshold for an account via PATCH + */ + async saveModelThreshold(email, modelId, pct) { + const store = Alpine.store('global'); + const dataStore = Alpine.store('data'); + + const account = dataStore.accounts.find(a => a.email === email); + if (!account) return; + + // Snapshot for rollback on failure + const previousModelThresholds = account.modelQuotaThresholds ? { ...account.modelQuotaThresholds } : {}; + + // Build full modelQuotaThresholds for API (full replacement, not merge) + const existingModelThresholds = { ...(account.modelQuotaThresholds || {}) }; + + // Preserve the account-level quotaThreshold + const quotaThreshold = account.quotaThreshold !== undefined ? account.quotaThreshold : null; + + try { + const { response, newPassword } = await window.utils.request( + `/api/accounts/${encodeURIComponent(email)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ quotaThreshold, modelQuotaThresholds: existingModelThresholds }) + }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const label = pct === 0 ? 'removed' : pct + '%'; + store.showToast(`${email.split('@')[0]} ${modelId} threshold: ${label}`, 'success'); + // Skip fetchData() — optimistic update is already applied, + // next polling cycle will sync server state + } else { + throw new Error(data.error || 'Failed to save threshold'); + } + } catch (e) { + // Revert optimistic update on failure + account.modelQuotaThresholds = previousModelThresholds; + dataStore.computeQuotaRows(); + store.showToast('Failed to save threshold: ' + e.message, 'error'); + } + }, + + /** + * Check if a specific marker is currently being dragged + */ + isDragging(q, row) { + return this.dragging.active && this.dragging.email === q.fullEmail && this.dragging.modelId === row.modelId; + }, + + /** + * Get the display percentage for a marker (live during drag, stored otherwise) + */ + getMarkerPct(q, row) { + if (this.isDragging(q, row)) return this.dragging.currentPct; + return q.thresholdPct; + }, + + /** + * Compute pixel offset for overlapping markers so stacked ones fan out. + * Markers within 2% of each other are considered overlapping. + * Returns a CSS pixel offset string (e.g., '6px' or '-6px'). + */ + getMarkerOffset(q, row, qIdx) { + const pct = this.getMarkerPct(q, row); + const visible = row.quotaInfo.filter(item => item.thresholdPct > 0 || this.isDragging(item, row)); + // Find all markers within 2% of this one + const cluster = []; + visible.forEach((item, idx) => { + const itemPct = this.getMarkerPct(item, row); + if (Math.abs(itemPct - pct) <= 2) { + cluster.push({ item, idx }); + } + }); + if (cluster.length <= 1) return '0px'; + // Find position of this marker within its cluster + const posInCluster = cluster.findIndex(c => c.item.fullEmail === q.fullEmail); + // Spread markers 10px apart, centered on the base position + const spread = 10; + const totalWidth = (cluster.length - 1) * spread; + return (posInCluster * spread - totalWidth / 2) + 'px'; + }, + + init() { + // Ensure data is fetched when this tab becomes active (skip initial trigger) + this.$watch('$store.global.activeTab', (val, oldVal) => { + if (val === 'models' && oldVal !== undefined) { + // Trigger recompute to ensure filters are applied + this.$nextTick(() => { + Alpine.store('data').computeQuotaRows(); + }); + } + }); + + // Initial compute if already on models tab + if (this.$store.global.activeTab === 'models') { + this.$nextTick(() => { + Alpine.store('data').computeQuotaRows(); + }); + } + }, + + /** + * Update model configuration (delegates to shared utility) + * @param {string} modelId - The model ID to update + * @param {object} configUpdates - Configuration updates (pinned, hidden) + */ + async updateModelConfig(modelId, configUpdates) { + return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates); + } +}); diff --git a/public/js/components/server-config.js b/public/js/components/server-config.js new file mode 100755 index 0000000000000000000000000000000000000000..0b8ae47378a8334447d0b971be19eabb01d4a4ec --- /dev/null +++ b/public/js/components/server-config.js @@ -0,0 +1,1203 @@ +/** + * Server Config Component + * Registers itself to window.Components for Alpine.js to consume + */ +window.Components = window.Components || {}; + +window.Components.serverConfig = () => ({ + serverConfig: {}, + loading: false, + advancedExpanded: false, + debounceTimers: {}, // Store debounce timers for each config field + + // Server presets state + serverPresets: [], + selectedServerPreset: '', + loadingPreset: false, + savingServerPreset: false, + deletingServerPreset: false, + newServerPresetName: '', + newServerPresetDescription: '', + editingPresetMode: false, + editingPresetOriginalName: '', + presetPreviewExpanded: false, + editingPresetConfig: false, + editingConfigDraft: { accountSelection: { strategy: 'hybrid' } }, + editingConfigErrors: {}, + savingPresetConfig: false, + configEditMode: 'ui', + editingJsonText: '', + jsonParseError: null, + + init() { + // Initial fetch if this is the active sub-tab + if (this.$store.global.settingsTab === 'server') { + this.fetchServerConfig(); + this.fetchServerPresets(); + } + + // Watch settings sub-tab (skip initial trigger) + this.$watch('$store.global.settingsTab', (tab, oldTab) => { + if (tab === 'server' && oldTab !== undefined) { + this.fetchServerConfig(); + this.fetchServerPresets(); + } + }); + + // Cancel config editing when switching presets + this.$watch('selectedServerPreset', () => { + if (this.editingPresetConfig) { + this.cancelPresetConfigEdit(); + } + }); + }, + + async fetchServerConfig() { + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/config', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error('Failed to fetch config'); + const data = await response.json(); + this.serverConfig = data.config || {}; + } catch (e) { + console.error('Failed to fetch server config:', e); + } + }, + + + + // Password management + passwordDialog: { + show: false, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }, + + showPasswordDialog() { + this.passwordDialog = { + show: true, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + }, + + hidePasswordDialog() { + this.passwordDialog = { + show: false, + oldPassword: '', + newPassword: '', + confirmPassword: '' + }; + }, + + async changePassword() { + const store = Alpine.store('global'); + const { oldPassword, newPassword, confirmPassword } = this.passwordDialog; + + if (newPassword !== confirmPassword) { + store.showToast(store.t('passwordsNotMatch'), 'error'); + return; + } + if (newPassword.length < 6) { + store.showToast(store.t('passwordTooShort'), 'error'); + return; + } + + try { + const { response } = await window.utils.request('/api/config/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldPassword, newPassword }) + }, store.webuiPassword); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || store.t('failedToChangePassword')); + } + + // Update stored password + store.webuiPassword = newPassword; + store.showToast(store.t('passwordChangedSuccess'), 'success'); + this.hidePasswordDialog(); + } catch (e) { + store.showToast(store.t('failedToChangePassword') + ': ' + e.message, 'error'); + } + }, + + // Toggle Developer Mode with instant save + async toggleDevMode(enabled) { + const store = Alpine.store('global'); + + // Optimistic update + const previousDevMode = this.serverConfig.devMode; + const previousDebug = this.serverConfig.debug; + this.serverConfig.devMode = enabled; + this.serverConfig.debug = enabled; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ devMode: enabled }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus'); + store.showToast(store.t('devModeToggled', { status }), 'success'); + // Update data store + Alpine.store('data').devMode = enabled; + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || store.t('failedToUpdateDevMode')); + } + } catch (e) { + // Rollback on error + this.serverConfig.devMode = previousDevMode; + this.serverConfig.debug = previousDebug; + store.showToast(store.t('failedToUpdateDevMode') + ': ' + e.message, 'error'); + } + }, + + // Toggle Token Cache with instant save + async toggleTokenCache(enabled) { + const store = Alpine.store('global'); + + // Optimistic update + const previousValue = this.serverConfig.persistTokenCache; + this.serverConfig.persistTokenCache = enabled; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ persistTokenCache: enabled }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus'); + store.showToast(store.t('tokenCacheToggled', { status }), 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || store.t('failedToUpdateTokenCache')); + } + } catch (e) { + // Rollback on error + this.serverConfig.persistTokenCache = previousValue; + store.showToast(store.t('failedToUpdateTokenCache') + ': ' + e.message, 'error'); + } + }, + + // Generic debounced save method for numeric configs with validation + async saveConfigField(fieldName, value, displayName, validator = null) { + const store = Alpine.store('global'); + + // Validate input if validator provided + if (validator) { + const validation = window.Validators.validate(value, validator, true); + if (!validation.isValid) { + // Rollback to clamped value + this.serverConfig[fieldName] = validation.value; + return; + } + value = validation.value; + } else { + value = parseInt(value); + } + + // Clear existing timer for this field + if (this.debounceTimers[fieldName]) { + clearTimeout(this.debounceTimers[fieldName]); + } + + // Optimistic update + const previousValue = this.serverConfig[fieldName]; + this.serverConfig[fieldName] = value; + + // Set new timer + this.debounceTimers[fieldName] = setTimeout(async () => { + try { + const payload = {}; + payload[fieldName] = value; + + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('fieldUpdated', { displayName, value }), 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || store.t('failedToUpdateField', { displayName })); + } + } catch (e) { + // Rollback on error + this.serverConfig[fieldName] = previousValue; + store.showToast(store.t('failedToUpdateField', { displayName }) + ': ' + e.message, 'error'); + } + }, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE); + }, + + // Individual toggle methods for each Advanced Tuning field with validation + toggleMaxRetries(value) { + const { MAX_RETRIES_MIN, MAX_RETRIES_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxRetries', value, 'Max Retries', + (v) => window.Validators.validateRange(v, MAX_RETRIES_MIN, MAX_RETRIES_MAX, 'Max Retries')); + }, + + toggleRetryBaseMs(value) { + const { RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('retryBaseMs', value, 'Retry Base Delay', + (v) => window.Validators.validateRange(v, RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX, 'Retry Base Delay')); + }, + + toggleRetryMaxMs(value) { + const { RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('retryMaxMs', value, 'Retry Max Delay', + (v) => window.Validators.validateRange(v, RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX, 'Retry Max Delay')); + }, + + toggleDefaultCooldownMs(value) { + const { DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown', + (v) => window.Validators.validateTimeout(v, DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX)); + }, + + toggleMaxWaitBeforeErrorMs(value) { + const { MAX_WAIT_MIN, MAX_WAIT_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold', + (v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX)); + }, + + toggleGlobalQuotaThreshold(value) { + const { GLOBAL_QUOTA_THRESHOLD_MIN, GLOBAL_QUOTA_THRESHOLD_MAX } = window.AppConstants.VALIDATION; + const store = Alpine.store('global'); + const pct = parseInt(value); + if (isNaN(pct) || pct < GLOBAL_QUOTA_THRESHOLD_MIN || pct > GLOBAL_QUOTA_THRESHOLD_MAX) return; + + // Store as percentage in UI, convert to fraction for backend + const fraction = pct / 100; + + if (this.debounceTimers['globalQuotaThreshold']) { + clearTimeout(this.debounceTimers['globalQuotaThreshold']); + } + + const previousValue = this.serverConfig.globalQuotaThreshold; + this.serverConfig.globalQuotaThreshold = fraction; + + this.debounceTimers['globalQuotaThreshold'] = setTimeout(async () => { + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ globalQuotaThreshold: fraction }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('fieldUpdated', { displayName: 'Minimum Quota Level', value: pct + '%' }), 'success'); + await this.fetchServerConfig(); + } else { + throw new Error(data.error || store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' })); + } + } catch (e) { + this.serverConfig.globalQuotaThreshold = previousValue; + store.showToast(store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }) + ': ' + e.message, 'error'); + } + }, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE); + }, + + async toggleRequestThrottling(enabled) { + const store = Alpine.store('global'); + const previousValue = this.serverConfig.requestThrottlingEnabled; + this.serverConfig.requestThrottlingEnabled = enabled; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ requestThrottlingEnabled: enabled }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(`Request Throttling ${enabled ? 'enabled' : 'disabled'}`, 'success'); + } else { + throw new Error(data.error || 'Failed to update'); + } + } catch (e) { + this.serverConfig.requestThrottlingEnabled = previousValue; + store.showToast('Failed to update Request Throttling: ' + e.message, 'error'); + } + }, + + toggleRequestDelayMs(value) { + const { REQUEST_DELAY_MIN, REQUEST_DELAY_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('requestDelayMs', value, 'Request Delay', + (v) => window.Validators.validateRange(v, REQUEST_DELAY_MIN, REQUEST_DELAY_MAX, 'Request Delay')); + }, + + toggleMaxAccounts(value) { + const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxAccounts', value, 'Max Accounts', + (v) => window.Validators.validateRange(v, MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX, 'Max Accounts')); + }, + + toggleRateLimitDedupWindowMs(value) { + const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window', + (v) => window.Validators.validateTimeout(v, RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX)); + }, + + toggleMaxConsecutiveFailures(value) { + const { MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxConsecutiveFailures', value, 'Max Consecutive Failures', + (v) => window.Validators.validateRange(v, MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX, 'Max Consecutive Failures')); + }, + + toggleExtendedCooldownMs(value) { + const { EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('extendedCooldownMs', value, 'Extended Cooldown', + (v) => window.Validators.validateTimeout(v, EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX)); + }, + + toggleMaxCapacityRetries(value) { + const { MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX } = window.AppConstants.VALIDATION; + this.saveConfigField('maxCapacityRetries', value, 'Max Capacity Retries', + (v) => window.Validators.validateRange(v, MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX, 'Max Capacity Retries')); + }, + + // Toggle Account Selection Strategy + async toggleStrategy(strategy) { + const store = Alpine.store('global'); + const validStrategies = ['sticky', 'round-robin', 'hybrid']; + + if (!validStrategies.includes(strategy)) { + store.showToast(store.t('invalidStrategy'), 'error'); + return; + } + + // Optimistic update + const previousValue = this.serverConfig.accountSelection?.strategy || 'hybrid'; + if (!this.serverConfig.accountSelection) { + this.serverConfig.accountSelection = {}; + } + this.serverConfig.accountSelection.strategy = strategy; + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ accountSelection: { strategy } }) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + const strategyLabel = this.getStrategyLabel(strategy); + store.showToast(store.t('strategyUpdated', { strategy: strategyLabel }), 'success'); + await this.fetchServerConfig(); // Confirm server state + } else { + throw new Error(data.error || store.t('failedToUpdateStrategy')); + } + } catch (e) { + // Rollback on error + if (!this.serverConfig.accountSelection) { + this.serverConfig.accountSelection = {}; + } + this.serverConfig.accountSelection.strategy = previousValue; + store.showToast(store.t('failedToUpdateStrategy') + ': ' + e.message, 'error'); + } + }, + + // Get display label for a strategy + getStrategyLabel(strategy) { + const store = Alpine.store('global'); + const labels = { + 'sticky': store.t('strategyStickyLabel'), + 'round-robin': store.t('strategyRoundRobinLabel'), + 'hybrid': store.t('strategyHybridLabel') + }; + return labels[strategy] || strategy; + }, + + // Get description for current strategy + currentStrategyDescription() { + const store = Alpine.store('global'); + const strategy = this.serverConfig.accountSelection?.strategy || 'hybrid'; + const descriptions = { + 'sticky': store.t('strategyStickyDesc'), + 'round-robin': store.t('strategyRoundRobinDesc'), + 'hybrid': store.t('strategyHybridDesc') + }; + return descriptions[strategy] || ''; + }, + + // ========================================== + // Server Configuration Presets + // ========================================== + + async fetchServerPresets() { + const password = Alpine.store('global').webuiPassword; + try { + const { response, newPassword } = await window.utils.request('/api/server/presets', {}, password); + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + if (data.status === 'ok') { + this.serverPresets = data.presets || []; + if (this.serverPresets.length > 0 && !this.selectedServerPreset) { + this.selectedServerPreset = this.serverPresets[0].name; + } + } + } catch (e) { + console.error('Failed to fetch server presets:', e); + } + }, + + /** + * Load a server preset — applies all config values via POST /api/config + */ + async loadServerPreset(name) { + const preset = this.serverPresets.find(p => p.name === name); + if (!preset) return; + + this.loadingPreset = true; + const store = Alpine.store('global'); + + try { + const { response, newPassword } = await window.utils.request('/api/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(preset.config) + }, store.webuiPassword); + + if (newPassword) store.webuiPassword = newPassword; + + const data = await response.json(); + if (data.status === 'ok') { + store.showToast(store.t('serverPresetLoaded', { name }) || `Preset "${name}" applied`, 'success'); + await this.fetchServerConfig(); + } else { + throw new Error(data.error || 'Failed to apply preset'); + } + } catch (e) { + store.showToast((store.t('failedToLoadServerPreset') || 'Failed to apply preset') + ': ' + e.message, 'error'); + } finally { + this.loadingPreset = false; + } + }, + + /** + * Save current server config as a new custom preset + */ + async saveCurrentAsServerPreset() { + this.editingPresetMode = false; + this.editingPresetOriginalName = ''; + this.newServerPresetName = ''; + this.newServerPresetDescription = ''; + document.getElementById('save_server_preset_modal').showModal(); + }, + + /** + * Edit an existing custom preset's name and description + */ + editServerPreset() { + const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); + if (!preset || preset.builtIn) return; + + this.editingPresetMode = true; + this.editingPresetOriginalName = preset.name; + this.newServerPresetName = preset.name; + this.newServerPresetDescription = preset.description || ''; + document.getElementById('save_server_preset_modal').showModal(); + }, + + /** + * Execute PATCH to update preset metadata + */ + async executeEditServerPreset() { + const name = this.newServerPresetName.trim(); + if (!name) { + Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error'); + return; + } + + this.savingServerPreset = true; + const store = Alpine.store('global'); + + try { + const payload = { name, description: this.newServerPresetDescription.trim() || '' }; + + const { response, newPassword } = await window.utils.request( + `/api/server/presets/${encodeURIComponent(this.editingPresetOriginalName)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + if (!response.ok) { + const errData = await response.json(); + throw new Error(errData.error || `HTTP ${response.status}`); + } + const data = await response.json(); + if (data.status === 'ok') { + this.serverPresets = data.presets || []; + this.selectedServerPreset = name; + this.editingPresetMode = false; + this.editingPresetOriginalName = ''; + this.newServerPresetName = ''; + this.newServerPresetDescription = ''; + store.showToast(store.t('serverPresetUpdated') || 'Preset updated', 'success'); + document.getElementById('save_server_preset_modal').close(); + } else { + throw new Error(data.error || 'Failed to update preset'); + } + } catch (e) { + store.showToast((store.t('failedToEditServerPreset') || 'Failed to update preset') + ': ' + e.message, 'error'); + } finally { + this.savingServerPreset = false; + } + }, + + async executeSaveServerPreset(name) { + if (!name || !name.trim()) { + Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error'); + return; + } + + this.savingServerPreset = true; + const store = Alpine.store('global'); + const password = store.webuiPassword; + + try { + // Extract relevant config fields (exclude sensitive/non-tunable) + const relevantKeys = [ + 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs', + 'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold', + 'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs', + 'maxCapacityRetries', 'switchAccountDelayMs', 'capacityBackoffTiersMs', + 'accountSelection' + ]; + const presetConfig = {}; + relevantKeys.forEach(k => { + if (this.serverConfig[k] !== undefined) { + presetConfig[k] = JSON.parse(JSON.stringify(this.serverConfig[k])); + } + }); + + const payload = { name: name.trim(), config: presetConfig }; + if (this.newServerPresetDescription.trim()) { + payload.description = this.newServerPresetDescription.trim(); + } + + const { response, newPassword } = await window.utils.request('/api/server/presets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }, password); + if (newPassword) store.webuiPassword = newPassword; + + if (!response.ok) { + const errData = await response.json(); + throw new Error(errData.error || `HTTP ${response.status}`); + } + const data = await response.json(); + if (data.status === 'ok') { + this.serverPresets = data.presets || []; + this.selectedServerPreset = name.trim(); + this.newServerPresetName = ''; + this.newServerPresetDescription = ''; + store.showToast(store.t('serverPresetSaved') || `Preset "${name}" saved`, 'success'); + document.getElementById('save_server_preset_modal').close(); + } else { + throw new Error(data.error || 'Failed to save preset'); + } + } catch (e) { + store.showToast((store.t('failedToSaveServerPreset') || 'Failed to save preset') + ': ' + e.message, 'error'); + } finally { + this.savingServerPreset = false; + } + }, + + async deleteSelectedServerPreset() { + if (!this.selectedServerPreset) return; + + // Check if built-in + const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); + if (preset?.builtIn) { + Alpine.store('global').showToast(Alpine.store('global').t('cannotDeleteBuiltIn') || 'Cannot delete built-in presets', 'warning'); + return; + } + + const store = Alpine.store('global'); + const translated = store.t('deletePresetConfirm', { name: this.selectedServerPreset }); + const confirmMsg = translated === 'deletePresetConfirm' ? `Delete preset "${this.selectedServerPreset}"?` : translated; + if (!confirm(confirmMsg)) return; + + this.deletingServerPreset = true; + + try { + const { response, newPassword } = await window.utils.request( + `/api/server/presets/${encodeURIComponent(this.selectedServerPreset)}`, + { method: 'DELETE' }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + if (!response.ok) { + const errData = await response.json(); + throw new Error(errData.error || `HTTP ${response.status}`); + } + const data = await response.json(); + if (data.status === 'ok') { + this.serverPresets = data.presets || []; + this.selectedServerPreset = this.serverPresets.length > 0 ? this.serverPresets[0].name : ''; + store.showToast(store.t('serverPresetDeleted') || 'Preset deleted', 'success'); + } else { + throw new Error(data.error || 'Failed to delete preset'); + } + } catch (e) { + store.showToast((store.t('failedToDeleteServerPreset') || 'Failed to delete preset') + ': ' + e.message, 'error'); + } finally { + this.deletingServerPreset = false; + } + }, + + isSelectedPresetBuiltIn() { + const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); + return preset?.builtIn === true; + }, + + /** + * Format a millisecond value to a human-readable string. + * e.g. 60000 → "1m", 1000 → "1s", 1500 → "1.5s", 90000 → "1m 30s" + */ + formatMsValue(ms) { + if (ms == null) return '—'; + if (ms < 1000) return ms + 'ms'; + const totalSeconds = ms / 1000; + if (totalSeconds < 60) { + return Number.isInteger(totalSeconds) ? totalSeconds + 's' : totalSeconds.toFixed(1) + 's'; + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (seconds === 0) return minutes + 'm'; + return minutes + 'm ' + (Number.isInteger(seconds) ? seconds : seconds.toFixed(1)) + 's'; + }, + + /** + * Get preview sections for the currently selected preset. + * Returns { strategy, strategyLabel, sections } where each section has { label, rows }. + * Each row has { label, value, differs } where differs is true when the preset + * value doesn't match the current running serverConfig. + */ + getPresetPreviewSections() { + const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); + if (!preset?.config) return null; + + const cfg = preset.config; + const cur = this.serverConfig; + const store = Alpine.store('global'); + + const strategy = cfg.accountSelection?.strategy || 'hybrid'; + const currentStrategy = cur.accountSelection?.strategy || 'hybrid'; + + const differs = (presetVal, currentVal) => { + if (presetVal == null && currentVal == null) return false; + if (presetVal == null || currentVal == null) return true; + return JSON.stringify(presetVal) !== JSON.stringify(currentVal); + }; + + const fmtQuota = (val) => { + if (!val || val === 0) return store.t('quotaDisabled') || 'Disabled'; + return Math.round(val * 100) + '%'; + }; + + const V = window.AppConstants.VALIDATION; + + const sections = [ + { + label: store.t('networkRetry') || 'Network Retry Settings', + rows: [ + { label: store.t('maxRetries') || 'Max Retries', value: cfg.maxRetries ?? '—', differs: differs(cfg.maxRetries, cur.maxRetries), key: 'maxRetries', min: V.MAX_RETRIES_MIN, max: V.MAX_RETRIES_MAX, step: 1 }, + { label: store.t('retryBaseDelay') || 'Retry Base Delay', value: this.formatMsValue(cfg.retryBaseMs), differs: differs(cfg.retryBaseMs, cur.retryBaseMs), key: 'retryBaseMs', min: V.RETRY_BASE_MS_MIN, max: V.RETRY_BASE_MS_MAX, step: 100, suffix: 'ms' }, + { label: store.t('retryMaxDelay') || 'Retry Max Delay', value: this.formatMsValue(cfg.retryMaxMs), differs: differs(cfg.retryMaxMs, cur.retryMaxMs), key: 'retryMaxMs', min: V.RETRY_MAX_MS_MIN, max: V.RETRY_MAX_MS_MAX, step: 1000, suffix: 'ms' }, + ] + }, + { + label: store.t('rateLimiting') || 'Rate Limiting', + rows: [ + { label: store.t('defaultCooldown') || 'Default Cooldown', value: this.formatMsValue(cfg.defaultCooldownMs), differs: differs(cfg.defaultCooldownMs, cur.defaultCooldownMs), key: 'defaultCooldownMs', min: V.DEFAULT_COOLDOWN_MIN, max: V.DEFAULT_COOLDOWN_MAX, step: 1000, suffix: 'ms' }, + { label: store.t('maxWaitThreshold') || 'Max Wait Before Error', value: this.formatMsValue(cfg.maxWaitBeforeErrorMs), differs: differs(cfg.maxWaitBeforeErrorMs, cur.maxWaitBeforeErrorMs), key: 'maxWaitBeforeErrorMs', min: V.MAX_WAIT_MIN, max: V.MAX_WAIT_MAX, step: 1000, suffix: 'ms' }, + { label: store.t('maxAccounts') || 'Max Accounts', value: cfg.maxAccounts ?? '—', differs: differs(cfg.maxAccounts, cur.maxAccounts), key: 'maxAccounts', min: V.MAX_ACCOUNTS_MIN, max: V.MAX_ACCOUNTS_MAX, step: 1 }, + { label: store.t('switchAccountDelay') || 'Switch Account Delay', value: this.formatMsValue(cfg.switchAccountDelayMs), differs: differs(cfg.switchAccountDelayMs, cur.switchAccountDelayMs), key: 'switchAccountDelayMs', min: V.SWITCH_ACCOUNT_DELAY_MIN, max: V.SWITCH_ACCOUNT_DELAY_MAX, step: 1000, suffix: 'ms' }, + ] + }, + { + label: store.t('quotaProtection') || 'Quota Protection', + rows: [ + { label: store.t('minimumQuotaLevel') || 'Minimum Quota Level', value: fmtQuota(cfg.globalQuotaThreshold), differs: differs(cfg.globalQuotaThreshold, cur.globalQuotaThreshold), key: 'globalQuotaThreshold', min: V.GLOBAL_QUOTA_THRESHOLD_MIN, max: V.GLOBAL_QUOTA_THRESHOLD_MAX, step: 1, suffix: '%' }, + ] + }, + { + label: store.t('errorHandlingTuning') || 'Error Handling', + rows: [ + { label: store.t('rateLimitDedupWindow') || 'Dedup Window', value: this.formatMsValue(cfg.rateLimitDedupWindowMs), differs: differs(cfg.rateLimitDedupWindowMs, cur.rateLimitDedupWindowMs), key: 'rateLimitDedupWindowMs', min: V.RATE_LIMIT_DEDUP_MIN, max: V.RATE_LIMIT_DEDUP_MAX, step: 1000, suffix: 'ms' }, + { label: store.t('maxConsecutiveFailures') || 'Max Consecutive Failures', value: cfg.maxConsecutiveFailures ?? '—', differs: differs(cfg.maxConsecutiveFailures, cur.maxConsecutiveFailures), key: 'maxConsecutiveFailures', min: V.MAX_CONSECUTIVE_FAILURES_MIN, max: V.MAX_CONSECUTIVE_FAILURES_MAX, step: 1 }, + { label: store.t('extendedCooldown') || 'Extended Cooldown', value: this.formatMsValue(cfg.extendedCooldownMs), differs: differs(cfg.extendedCooldownMs, cur.extendedCooldownMs), key: 'extendedCooldownMs', min: V.EXTENDED_COOLDOWN_MIN, max: V.EXTENDED_COOLDOWN_MAX, step: 1000, suffix: 'ms' }, + { label: store.t('maxCapacityRetries') || 'Max Capacity Retries', value: cfg.maxCapacityRetries ?? '—', differs: differs(cfg.maxCapacityRetries, cur.maxCapacityRetries), key: 'maxCapacityRetries', min: V.MAX_CAPACITY_RETRIES_MIN, max: V.MAX_CAPACITY_RETRIES_MAX, step: 1 }, + { label: store.t('capacityBackoffTiers') || 'Capacity Backoff Tiers', value: (cfg.capacityBackoffTiersMs || []).map(v => this.formatMsValue(v)).join(', ') || '—', differs: differs(cfg.capacityBackoffTiersMs, cur.capacityBackoffTiersMs), key: 'capacityBackoffTiersMs', type: 'text' }, + ] + } + ]; + + // Add hybrid-only sections when strategy is hybrid + if (strategy === 'hybrid') { + const as = cfg.accountSelection || {}; + const curAs = cur.accountSelection || {}; + + // Scoring Weights + const w = as.weights || {}; + const curW = curAs.weights || {}; + sections.push({ + label: store.t('scoringWeights') || 'Scoring Weights', + hybridOnly: true, + rows: [ + { label: store.t('weightHealth') || 'Health Weight', value: w.health ?? '—', differs: differs(w.health, curW.health), key: 'w_health', min: V.W_HEALTH_MIN, max: V.W_HEALTH_MAX, step: 0.1 }, + { label: store.t('weightTokens') || 'Tokens Weight', value: w.tokens ?? '—', differs: differs(w.tokens, curW.tokens), key: 'w_tokens', min: V.W_TOKENS_MIN, max: V.W_TOKENS_MAX, step: 0.1 }, + { label: store.t('weightQuota') || 'Quota Weight', value: w.quota ?? '—', differs: differs(w.quota, curW.quota), key: 'w_quota', min: V.W_QUOTA_MIN, max: V.W_QUOTA_MAX, step: 0.1 }, + { label: store.t('weightLru') || 'LRU Weight', value: w.lru ?? '—', differs: differs(w.lru, curW.lru), key: 'w_lru', min: V.W_LRU_MIN, max: V.W_LRU_MAX, step: 0.01 }, + ] + }); + + // Health Score + const hs = as.healthScore || {}; + const curHs = curAs.healthScore || {}; + sections.push({ + label: store.t('healthScoreSection') || 'Health Score', + hybridOnly: true, + rows: [ + { label: store.t('hsInitial') || 'Initial Score', value: hs.initial ?? '—', differs: differs(hs.initial, curHs.initial), key: 'hs_initial', min: V.HS_INITIAL_MIN, max: V.HS_INITIAL_MAX, step: 1 }, + { label: store.t('hsSuccessReward') || 'Success Reward', value: hs.successReward ?? '—', differs: differs(hs.successReward, curHs.successReward), key: 'hs_successReward', min: V.HS_SUCCESS_REWARD_MIN, max: V.HS_SUCCESS_REWARD_MAX, step: 1 }, + { label: store.t('hsRateLimitPenalty') || 'Rate Limit Penalty', value: hs.rateLimitPenalty ?? '—', differs: differs(hs.rateLimitPenalty, curHs.rateLimitPenalty), key: 'hs_rateLimitPenalty', min: V.HS_RATE_LIMIT_PENALTY_MIN, max: V.HS_RATE_LIMIT_PENALTY_MAX, step: 1 }, + { label: store.t('hsFailurePenalty') || 'Failure Penalty', value: hs.failurePenalty ?? '—', differs: differs(hs.failurePenalty, curHs.failurePenalty), key: 'hs_failurePenalty', min: V.HS_FAILURE_PENALTY_MIN, max: V.HS_FAILURE_PENALTY_MAX, step: 1 }, + { label: store.t('hsRecoveryPerHour') || 'Recovery/Hour', value: hs.recoveryPerHour ?? '—', differs: differs(hs.recoveryPerHour, curHs.recoveryPerHour), key: 'hs_recoveryPerHour', min: V.HS_RECOVERY_PER_HOUR_MIN, max: V.HS_RECOVERY_PER_HOUR_MAX, step: 1 }, + { label: store.t('hsMinUsable') || 'Min Usable Score', value: hs.minUsable ?? '—', differs: differs(hs.minUsable, curHs.minUsable), key: 'hs_minUsable', min: V.HS_MIN_USABLE_MIN, max: V.HS_MIN_USABLE_MAX, step: 1 }, + { label: store.t('hsMaxScore') || 'Max Score', value: hs.maxScore ?? '—', differs: differs(hs.maxScore, curHs.maxScore), key: 'hs_maxScore', min: V.HS_MAX_SCORE_MIN, max: V.HS_MAX_SCORE_MAX, step: 1 }, + ] + }); + + // Token Bucket + const tb = as.tokenBucket || {}; + const curTb = curAs.tokenBucket || {}; + sections.push({ + label: store.t('tokenBucketSection') || 'Token Bucket', + hybridOnly: true, + rows: [ + { label: store.t('tbMaxTokens') || 'Max Tokens', value: tb.maxTokens ?? '—', differs: differs(tb.maxTokens, curTb.maxTokens), key: 'tb_maxTokens', min: V.TB_MAX_TOKENS_MIN, max: V.TB_MAX_TOKENS_MAX, step: 1 }, + { label: store.t('tbTokensPerMinute') || 'Tokens/Minute', value: tb.tokensPerMinute ?? '—', differs: differs(tb.tokensPerMinute, curTb.tokensPerMinute), key: 'tb_tokensPerMinute', min: V.TB_TOKENS_PER_MINUTE_MIN, max: V.TB_TOKENS_PER_MINUTE_MAX, step: 1 }, + { label: store.t('tbInitialTokens') || 'Initial Tokens', value: tb.initialTokens ?? '—', differs: differs(tb.initialTokens, curTb.initialTokens), key: 'tb_initialTokens', min: V.TB_INITIAL_TOKENS_MIN, max: V.TB_INITIAL_TOKENS_MAX, step: 1 }, + ] + }); + + // Quota Awareness + const q = as.quota || {}; + const curQ = curAs.quota || {}; + sections.push({ + label: store.t('quotaAwarenessSection') || 'Quota Awareness', + hybridOnly: true, + rows: [ + { label: store.t('qLowThreshold') || 'Low Threshold', value: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) + '%' : '—', differs: differs(q.lowThreshold, curQ.lowThreshold), key: 'q_lowThreshold', min: V.Q_LOW_THRESHOLD_MIN, max: V.Q_LOW_THRESHOLD_MAX, step: 1, suffix: '%' }, + { label: store.t('qCriticalThreshold') || 'Critical Threshold', value: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) + '%' : '—', differs: differs(q.criticalThreshold, curQ.criticalThreshold), key: 'q_criticalThreshold', min: V.Q_CRITICAL_THRESHOLD_MIN, max: V.Q_CRITICAL_THRESHOLD_MAX, step: 1, suffix: '%' }, + { label: store.t('qStaleMs') || 'Stale Data Timeout', value: this.formatMsValue(q.staleMs), differs: differs(q.staleMs, curQ.staleMs), key: 'q_staleMs', min: V.Q_STALE_MS_MIN, max: V.Q_STALE_MS_MAX, step: 1000, suffix: 'ms' }, + ] + }); + } + + return { + strategy, + strategyLabel: this.getStrategyLabel(strategy), + strategyDiffers: differs(strategy, currentStrategy), + sections + }; + }, + + // ========================================== + // Inline Preset Config Editing + // ========================================== + + /** + * Flatten a nested config object into a flat draft for UI editing. + * Converts fractions to percentages where needed. + */ + flattenConfigToDraft(cfg) { + const as = cfg.accountSelection || {}; + const hs = as.healthScore || {}; + const tb = as.tokenBucket || {}; + const q = as.quota || {}; + const w = as.weights || {}; + return { + maxRetries: cfg.maxRetries, + retryBaseMs: cfg.retryBaseMs, + retryMaxMs: cfg.retryMaxMs, + defaultCooldownMs: cfg.defaultCooldownMs, + maxWaitBeforeErrorMs: cfg.maxWaitBeforeErrorMs, + maxAccounts: cfg.maxAccounts, + globalQuotaThreshold: cfg.globalQuotaThreshold ? Math.round(cfg.globalQuotaThreshold * 100) : 0, + rateLimitDedupWindowMs: cfg.rateLimitDedupWindowMs, + maxConsecutiveFailures: cfg.maxConsecutiveFailures, + extendedCooldownMs: cfg.extendedCooldownMs, + maxCapacityRetries: cfg.maxCapacityRetries, + switchAccountDelayMs: cfg.switchAccountDelayMs, + capacityBackoffTiersMs: Array.isArray(cfg.capacityBackoffTiersMs) ? cfg.capacityBackoffTiersMs.join(', ') : '', + accountSelection: { strategy: as.strategy || 'hybrid' }, + // Health score (prefixed hs_) + hs_initial: hs.initial, + hs_successReward: hs.successReward, + hs_rateLimitPenalty: hs.rateLimitPenalty, + hs_failurePenalty: hs.failurePenalty, + hs_recoveryPerHour: hs.recoveryPerHour, + hs_minUsable: hs.minUsable, + hs_maxScore: hs.maxScore, + // Token bucket (prefixed tb_) + tb_maxTokens: tb.maxTokens, + tb_tokensPerMinute: tb.tokensPerMinute, + tb_initialTokens: tb.initialTokens, + // Quota (prefixed q_, fractions → percentages) + q_lowThreshold: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) : 0, + q_criticalThreshold: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) : 0, + q_staleMs: q.staleMs, + // Weights (prefixed w_) + w_health: w.health, + w_tokens: w.tokens, + w_quota: w.quota, + w_lru: w.lru, + }; + }, + + /** + * Build a nested config object from a flat UI draft. + * Converts percentages back to fractions where needed. + */ + buildConfigFromDraft(draft) { + const cfg = { + maxRetries: draft.maxRetries, + retryBaseMs: draft.retryBaseMs, + retryMaxMs: draft.retryMaxMs, + defaultCooldownMs: draft.defaultCooldownMs, + maxWaitBeforeErrorMs: draft.maxWaitBeforeErrorMs, + maxAccounts: draft.maxAccounts, + globalQuotaThreshold: (draft.globalQuotaThreshold || 0) / 100, + rateLimitDedupWindowMs: draft.rateLimitDedupWindowMs, + maxConsecutiveFailures: draft.maxConsecutiveFailures, + extendedCooldownMs: draft.extendedCooldownMs, + maxCapacityRetries: draft.maxCapacityRetries, + switchAccountDelayMs: draft.switchAccountDelayMs, + }; + + // Parse capacityBackoffTiersMs from comma-separated string + if (typeof draft.capacityBackoffTiersMs === 'string' && draft.capacityBackoffTiersMs.trim()) { + const tiers = draft.capacityBackoffTiersMs.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n)); + if (tiers.length > 0) cfg.capacityBackoffTiersMs = tiers; + } else if (Array.isArray(draft.capacityBackoffTiersMs)) { + cfg.capacityBackoffTiersMs = draft.capacityBackoffTiersMs; + } + + // Build accountSelection with nested objects + cfg.accountSelection = { + strategy: draft.accountSelection?.strategy || 'hybrid', + healthScore: { + initial: draft.hs_initial, + successReward: draft.hs_successReward, + rateLimitPenalty: draft.hs_rateLimitPenalty, + failurePenalty: draft.hs_failurePenalty, + recoveryPerHour: draft.hs_recoveryPerHour, + minUsable: draft.hs_minUsable, + maxScore: draft.hs_maxScore, + }, + tokenBucket: { + maxTokens: draft.tb_maxTokens, + tokensPerMinute: draft.tb_tokensPerMinute, + initialTokens: draft.tb_initialTokens, + }, + quota: { + lowThreshold: (draft.q_lowThreshold || 0) / 100, + criticalThreshold: (draft.q_criticalThreshold || 0) / 100, + staleMs: draft.q_staleMs, + }, + weights: { + health: draft.w_health, + tokens: draft.w_tokens, + quota: draft.w_quota, + lru: draft.w_lru, + } + }; + + // Clean undefined values from nested objects + for (const subKey of ['healthScore', 'tokenBucket', 'quota', 'weights']) { + const obj = cfg.accountSelection[subKey]; + const cleaned = {}; + let hasValues = false; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined && v !== null) { + cleaned[k] = v; + hasValues = true; + } + } + if (hasValues) { + cfg.accountSelection[subKey] = cleaned; + } else { + delete cfg.accountSelection[subKey]; + } + } + + return cfg; + }, + + enterPresetConfigEdit() { + const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset); + if (!preset?.config || preset.builtIn) return; + + this.editingConfigDraft = this.flattenConfigToDraft(preset.config); + this.editingConfigErrors = {}; + this.editingPresetConfig = true; + this.presetPreviewExpanded = true; + this.configEditMode = 'ui'; + this.editingJsonText = ''; + this.jsonParseError = null; + }, + + cancelPresetConfigEdit() { + this.editingPresetConfig = false; + this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } }; + this.editingConfigErrors = {}; + this.configEditMode = 'ui'; + this.editingJsonText = ''; + this.jsonParseError = null; + }, + + switchConfigEditMode(mode) { + if (mode === this.configEditMode) return; + + if (mode === 'json') { + // UI → JSON: convert flat draft to nested config, then stringify + const nested = this.buildConfigFromDraft(this.editingConfigDraft); + this.editingJsonText = JSON.stringify(nested, null, 2); + this.jsonParseError = null; + this.editingConfigErrors = {}; + this.configEditMode = 'json'; + } else { + // JSON → UI: parse nested config and flatten to draft + try { + const parsed = JSON.parse(this.editingJsonText); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; + return; + } + this.editingConfigDraft = this.flattenConfigToDraft(parsed); + this.editingConfigErrors = {}; + this.jsonParseError = null; + // Re-validate all numeric fields + const allKeys = [ + 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs', + 'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold', + 'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs', + 'maxCapacityRetries', 'switchAccountDelayMs', + 'hs_initial', 'hs_successReward', 'hs_rateLimitPenalty', 'hs_failurePenalty', + 'hs_recoveryPerHour', 'hs_minUsable', 'hs_maxScore', + 'tb_maxTokens', 'tb_tokensPerMinute', 'tb_initialTokens', + 'q_lowThreshold', 'q_criticalThreshold', 'q_staleMs', + 'w_health', 'w_tokens', 'w_quota', 'w_lru' + ]; + allKeys.forEach(k => { + if (this.editingConfigDraft[k] !== undefined) this.validatePresetConfigField(k, this.editingConfigDraft[k]); + }); + // Validate capacityBackoffTiersMs text + this.validatePresetConfigField('capacityBackoffTiersMs', this.editingConfigDraft.capacityBackoffTiersMs); + this.configEditMode = 'ui'; + } catch (e) { + this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; + } + } + }, + + validateJsonText() { + try { + const parsed = JSON.parse(this.editingJsonText); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; + } else { + this.jsonParseError = null; + } + } catch { + this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON'; + } + }, + + validatePresetConfigField(key, value) { + const V = window.AppConstants.VALIDATION; + + // Special text validation for capacityBackoffTiersMs + if (key === 'capacityBackoffTiersMs') { + const str = String(value || '').trim(); + if (!str) { + delete this.editingConfigErrors[key]; + return; + } + const parts = str.split(',').map(s => s.trim()); + if (parts.length < V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH || parts.length > V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH) { + this.editingConfigErrors[key] = `${V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH}–${V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH} values`; + return; + } + const allValid = parts.every(s => { + const n = Number(s); + return !isNaN(n) && n >= V.CAPACITY_BACKOFF_TIER_MIN && n <= V.CAPACITY_BACKOFF_TIER_MAX; + }); + if (!allValid) { + this.editingConfigErrors[key] = `each ${V.CAPACITY_BACKOFF_TIER_MIN}–${V.CAPACITY_BACKOFF_TIER_MAX}`; + } else { + delete this.editingConfigErrors[key]; + } + return; + } + + const numVal = Number(value); + const ranges = { + maxRetries: [V.MAX_RETRIES_MIN, V.MAX_RETRIES_MAX], + retryBaseMs: [V.RETRY_BASE_MS_MIN, V.RETRY_BASE_MS_MAX], + retryMaxMs: [V.RETRY_MAX_MS_MIN, V.RETRY_MAX_MS_MAX], + defaultCooldownMs: [V.DEFAULT_COOLDOWN_MIN, V.DEFAULT_COOLDOWN_MAX], + maxWaitBeforeErrorMs: [V.MAX_WAIT_MIN, V.MAX_WAIT_MAX], + maxAccounts: [V.MAX_ACCOUNTS_MIN, V.MAX_ACCOUNTS_MAX], + globalQuotaThreshold: [V.GLOBAL_QUOTA_THRESHOLD_MIN, V.GLOBAL_QUOTA_THRESHOLD_MAX], + rateLimitDedupWindowMs: [V.RATE_LIMIT_DEDUP_MIN, V.RATE_LIMIT_DEDUP_MAX], + maxConsecutiveFailures: [V.MAX_CONSECUTIVE_FAILURES_MIN, V.MAX_CONSECUTIVE_FAILURES_MAX], + extendedCooldownMs: [V.EXTENDED_COOLDOWN_MIN, V.EXTENDED_COOLDOWN_MAX], + maxCapacityRetries: [V.MAX_CAPACITY_RETRIES_MIN, V.MAX_CAPACITY_RETRIES_MAX], + switchAccountDelayMs: [V.SWITCH_ACCOUNT_DELAY_MIN, V.SWITCH_ACCOUNT_DELAY_MAX], + // Health score + hs_initial: [V.HS_INITIAL_MIN, V.HS_INITIAL_MAX], + hs_successReward: [V.HS_SUCCESS_REWARD_MIN, V.HS_SUCCESS_REWARD_MAX], + hs_rateLimitPenalty: [V.HS_RATE_LIMIT_PENALTY_MIN, V.HS_RATE_LIMIT_PENALTY_MAX], + hs_failurePenalty: [V.HS_FAILURE_PENALTY_MIN, V.HS_FAILURE_PENALTY_MAX], + hs_recoveryPerHour: [V.HS_RECOVERY_PER_HOUR_MIN, V.HS_RECOVERY_PER_HOUR_MAX], + hs_minUsable: [V.HS_MIN_USABLE_MIN, V.HS_MIN_USABLE_MAX], + hs_maxScore: [V.HS_MAX_SCORE_MIN, V.HS_MAX_SCORE_MAX], + // Token bucket + tb_maxTokens: [V.TB_MAX_TOKENS_MIN, V.TB_MAX_TOKENS_MAX], + tb_tokensPerMinute: [V.TB_TOKENS_PER_MINUTE_MIN, V.TB_TOKENS_PER_MINUTE_MAX], + tb_initialTokens: [V.TB_INITIAL_TOKENS_MIN, V.TB_INITIAL_TOKENS_MAX], + // Quota + q_lowThreshold: [V.Q_LOW_THRESHOLD_MIN, V.Q_LOW_THRESHOLD_MAX], + q_criticalThreshold: [V.Q_CRITICAL_THRESHOLD_MIN, V.Q_CRITICAL_THRESHOLD_MAX], + q_staleMs: [V.Q_STALE_MS_MIN, V.Q_STALE_MS_MAX], + // Weights + w_health: [V.W_HEALTH_MIN, V.W_HEALTH_MAX], + w_tokens: [V.W_TOKENS_MIN, V.W_TOKENS_MAX], + w_quota: [V.W_QUOTA_MIN, V.W_QUOTA_MAX], + w_lru: [V.W_LRU_MIN, V.W_LRU_MAX], + }; + const range = ranges[key]; + if (!range) return; + + if (isNaN(numVal) || numVal < range[0] || numVal > range[1]) { + this.editingConfigErrors[key] = `${range[0]}–${range[1]}`; + } else { + delete this.editingConfigErrors[key]; + this.editingConfigDraft[key] = numVal; + } + }, + + hasPresetConfigErrors() { + if (this.configEditMode === 'json') return !!this.jsonParseError; + return Object.keys(this.editingConfigErrors).length > 0; + }, + + async savePresetConfig() { + if (this.hasPresetConfigErrors() || this.savingPresetConfig) return; + + const store = Alpine.store('global'); + const presetName = this.selectedServerPreset; + if (!presetName) return; + + let configPayload; + if (this.configEditMode === 'json') { + // Parse JSON textarea directly into config payload + try { + configPayload = JSON.parse(this.editingJsonText); + } catch { + this.jsonParseError = store.t('invalidJson') || 'Invalid JSON'; + return; + } + // Must be a plain object (not null, not an array, not a primitive) + if (typeof configPayload !== 'object' || configPayload === null || Array.isArray(configPayload)) { + this.jsonParseError = store.t('invalidJson') || 'Invalid JSON'; + return; + } + } else { + // Build nested config from flat UI draft + configPayload = this.buildConfigFromDraft(this.editingConfigDraft); + } + + this.savingPresetConfig = true; + try { + const { response, newPassword } = await window.utils.request( + `/api/server/presets/${encodeURIComponent(presetName)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: configPayload }) + }, + store.webuiPassword + ); + if (newPassword) store.webuiPassword = newPassword; + + if (!response.ok) { + const errData = await response.json(); + throw new Error(errData.error || `HTTP ${response.status}`); + } + const data = await response.json(); + if (data.status === 'ok') { + this.serverPresets = data.presets || []; + this.editingPresetConfig = false; + this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } }; + this.editingConfigErrors = {}; + this.configEditMode = 'ui'; + this.editingJsonText = ''; + this.jsonParseError = null; + store.showToast(store.t('presetConfigSaved') || 'Preset config updated', 'success'); + } else { + throw new Error(data.error || 'Failed to save preset config'); + } + } catch (e) { + store.showToast((store.t('failedToSavePresetConfig') || 'Failed to update preset config') + ': ' + e.message, 'error'); + } finally { + this.savingPresetConfig = false; + } + } +}); diff --git a/public/js/config/constants.js b/public/js/config/constants.js new file mode 100755 index 0000000000000000000000000000000000000000..59074753b902cfd50c4888efbbf27a6a49eb4250 --- /dev/null +++ b/public/js/config/constants.js @@ -0,0 +1,165 @@ +/** + * Application Constants + * Centralized configuration values and magic numbers + */ +window.AppConstants = window.AppConstants || {}; + +/** + * Time intervals (in milliseconds) + */ +window.AppConstants.INTERVALS = { + // Dashboard refresh interval (5 minutes) + DASHBOARD_REFRESH: 300000, + + // OAuth message handler timeout (5 minutes) + OAUTH_MESSAGE_TIMEOUT: 300000, + + // Server config debounce delay + CONFIG_DEBOUNCE: 500, + + // General short delay (for UI transitions) + SHORT_DELAY: 2000 +}; + +/** + * Data limits and quotas + */ +window.AppConstants.LIMITS = { + // Default log limit + DEFAULT_LOG_LIMIT: 2000, + + // Minimum quota value + MIN_QUOTA: 100, + + // Percentage base (for calculations) + PERCENTAGE_BASE: 100, + + // Max per-account breakdown rows before showing "+N more" toggle + ACCOUNT_BREAKDOWN_LIMIT: 10 +}; + +/** + * Validation ranges + */ +window.AppConstants.VALIDATION = { + // Port range + PORT_MIN: 1, + PORT_MAX: 65535, + + // Timeout range (0 - 5 minutes) + TIMEOUT_MIN: 0, + TIMEOUT_MAX: 300000, + + // Log limit range + LOG_LIMIT_MIN: 100, + LOG_LIMIT_MAX: 10000, + + // Retry configuration ranges + MAX_RETRIES_MIN: 1, + MAX_RETRIES_MAX: 20, + + RETRY_BASE_MS_MIN: 100, + RETRY_BASE_MS_MAX: 10000, + + RETRY_MAX_MS_MIN: 1000, + RETRY_MAX_MS_MAX: 120000, + + // Cooldown range (1s - 5 minutes) + DEFAULT_COOLDOWN_MIN: 1000, + DEFAULT_COOLDOWN_MAX: 300000, + + // Max wait threshold (0 - 10 minutes) + MAX_WAIT_MIN: 0, + MAX_WAIT_MAX: 600000, + + // Max accounts range (1 - 100) + MAX_ACCOUNTS_MIN: 1, + MAX_ACCOUNTS_MAX: 100, + + // Rate limit dedup window (1 - 30 seconds) + RATE_LIMIT_DEDUP_MIN: 1000, + RATE_LIMIT_DEDUP_MAX: 30000, + + // Consecutive failures (1 - 10) + MAX_CONSECUTIVE_FAILURES_MIN: 1, + MAX_CONSECUTIVE_FAILURES_MAX: 10, + + // Extended cooldown (10 seconds - 5 minutes) + EXTENDED_COOLDOWN_MIN: 10000, + EXTENDED_COOLDOWN_MAX: 300000, + + // Capacity retries (1 - 10) + MAX_CAPACITY_RETRIES_MIN: 1, + MAX_CAPACITY_RETRIES_MAX: 10, + + // Global quota threshold (0 - 99%) + GLOBAL_QUOTA_THRESHOLD_MIN: 0, + GLOBAL_QUOTA_THRESHOLD_MAX: 99, + + // Request delay (100 - 5000ms) + REQUEST_DELAY_MIN: 100, + REQUEST_DELAY_MAX: 5000, + + // Switch account delay (1s - 60s) + SWITCH_ACCOUNT_DELAY_MIN: 1000, + SWITCH_ACCOUNT_DELAY_MAX: 60000, + + // Capacity backoff tiers + CAPACITY_BACKOFF_TIER_MIN: 1000, + CAPACITY_BACKOFF_TIER_MAX: 300000, + CAPACITY_BACKOFF_TIERS_MIN_LENGTH: 1, + CAPACITY_BACKOFF_TIERS_MAX_LENGTH: 10, + + // Health score fields + HS_INITIAL_MIN: 0, + HS_INITIAL_MAX: 100, + HS_SUCCESS_REWARD_MIN: 0, + HS_SUCCESS_REWARD_MAX: 20, + HS_RATE_LIMIT_PENALTY_MIN: -50, + HS_RATE_LIMIT_PENALTY_MAX: 0, + HS_FAILURE_PENALTY_MIN: -50, + HS_FAILURE_PENALTY_MAX: 0, + HS_RECOVERY_PER_HOUR_MIN: 0, + HS_RECOVERY_PER_HOUR_MAX: 20, + HS_MIN_USABLE_MIN: 0, + HS_MIN_USABLE_MAX: 100, + HS_MAX_SCORE_MIN: 1, + HS_MAX_SCORE_MAX: 200, + + // Token bucket fields + TB_MAX_TOKENS_MIN: 5, + TB_MAX_TOKENS_MAX: 200, + TB_TOKENS_PER_MINUTE_MIN: 1, + TB_TOKENS_PER_MINUTE_MAX: 60, + TB_INITIAL_TOKENS_MIN: 1, + TB_INITIAL_TOKENS_MAX: 200, + + // Quota awareness fields (displayed as 0-99% in UI) + Q_LOW_THRESHOLD_MIN: 0, + Q_LOW_THRESHOLD_MAX: 99, + Q_CRITICAL_THRESHOLD_MIN: 0, + Q_CRITICAL_THRESHOLD_MAX: 99, + Q_STALE_MS_MIN: 30000, + Q_STALE_MS_MAX: 3600000, + + // Weights fields + W_HEALTH_MIN: 0, + W_HEALTH_MAX: 20, + W_TOKENS_MIN: 0, + W_TOKENS_MAX: 20, + W_QUOTA_MIN: 0, + W_QUOTA_MAX: 20, + W_LRU_MIN: 0, + W_LRU_MAX: 5 +}; + +/** + * UI Constants + */ +window.AppConstants.UI = { + // Toast auto-dismiss duration + TOAST_DURATION: 3000, + + // Loading spinner delay + LOADING_DELAY: 200 +}; diff --git a/public/js/data-store.js b/public/js/data-store.js new file mode 100755 index 0000000000000000000000000000000000000000..1c5bb8f51ebe72c8767f129d276199f57210768c --- /dev/null +++ b/public/js/data-store.js @@ -0,0 +1,538 @@ +/** + * Data Store + * Holds Accounts, Models, and Computed Quota Rows + * Shared between Dashboard and AccountManager + */ + +// utils is loaded globally as window.utils in utils.js + +document.addEventListener('alpine:init', () => { + Alpine.store('data', { + accounts: [], + models: [], // Source of truth + modelConfig: {}, // Model metadata (hidden, pinned, alias) + quotaRows: [], // Filtered view + usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true) + globalQuotaThreshold: 0, // Global minimum quota threshold (fraction 0-0.99) + maxAccounts: 10, // Maximum number of accounts allowed (from config) + devMode: false, // Developer mode flag (from server config) + placeholderMode: false, // Inject placeholder account data for UI testing + placeholderIncludeReal: true, // Include real accounts alongside placeholder data + _realAccounts: null, // Stash for real accounts when placeholder mode is on + _realModels: null, // Stash for real models when placeholder mode is on + loading: false, + initialLoad: true, // Track first load for skeleton screen + connectionStatus: 'connecting', + lastUpdated: '-', + healthCheckTimer: null, + + // Filters state + filters: { + account: 'all', + family: 'all', + search: '', + sortCol: 'avgQuota', + sortAsc: true + }, + + // Settings for calculation + // We need to access global settings? Or duplicate? + // Let's assume settings are passed or in another store. + // For simplicity, let's keep relevant filters here. + + init() { + // Restore from cache first for instant render + this.loadFromCache(); + + // Restore placeholder mode from persisted settings + // Read localStorage directly since settings store may not be initialized yet + try { + const saved = JSON.parse(localStorage.getItem('antigravity_settings') || '{}'); + if (saved.placeholderMode) { + this.setPlaceholderMode(true, saved.placeholderIncludeReal !== false); + } + } catch (e) { /* ignore parse errors */ } + + // Watch filters to recompute + // Alpine stores don't have $watch automatically unless inside a component? + // We can manually call compute when filters change. + + // Start health check monitoring + this.startHealthCheck(); + }, + + loadFromCache() { + try { + const cached = localStorage.getItem('ag_data_cache'); + if (cached) { + const data = JSON.parse(cached); + const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + + // Check TTL + if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) { + if (window.UILogger) window.UILogger.debug('Cache expired, skipping restoration'); + localStorage.removeItem('ag_data_cache'); + return; + } + + // Basic validity check + if (data.accounts && data.models) { + this.accounts = data.accounts; + this.models = data.models; + this.modelConfig = data.modelConfig || {}; + this.usageHistory = data.usageHistory || {}; + + // Don't show loading on initial load if we have cache + this.initialLoad = false; + this.computeQuotaRows(); + if (window.UILogger) window.UILogger.debug('Restored data from cache'); + } + } + } catch (e) { + if (window.UILogger) window.UILogger.debug('Failed to load cache', e.message); + } + }, + + saveToCache() { + try { + const cacheData = { + accounts: this.accounts, + models: this.models, + modelConfig: this.modelConfig, + usageHistory: this.usageHistory, + timestamp: Date.now() + }; + localStorage.setItem('ag_data_cache', JSON.stringify(cacheData)); + } catch (e) { + if (window.UILogger) window.UILogger.debug('Failed to save cache', e.message); + } + }, + + async fetchData() { + // Only show skeleton on initial load if we didn't restore from cache + if (this.initialLoad) { + this.loading = true; + } + try { + // Get password from global store + const password = Alpine.store('global').webuiPassword; + + // Include history for dashboard (single API call optimization) + const url = '/account-limits?includeHistory=true'; + const { response, newPassword } = await window.utils.request(url, {}, password); + + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const data = await response.json(); + this.accounts = data.accounts || []; + if (data.models && data.models.length > 0) { + this.models = data.models; + } + this.modelConfig = data.modelConfig || {}; + this.globalQuotaThreshold = data.globalQuotaThreshold || 0; + + // Store usage history if included (for dashboard) + if (data.history) { + this.usageHistory = data.history; + } + + this.saveToCache(); // Save fresh data + + // Re-inject placeholder data if active + if (this.placeholderMode) { + this._realAccounts = [...this.accounts]; + this._realModels = [...this.models]; + const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData(); + if (this.placeholderIncludeReal) { + this.accounts = [...this._realAccounts, ...fakeAccounts]; + const modelSet = new Set([...this._realModels, ...fakeModels]); + this.models = Array.from(modelSet).sort(); + } else { + this.accounts = fakeAccounts; + this.models = fakeModels; + } + } + + this.computeQuotaRows(); + + this.lastUpdated = new Date().toLocaleTimeString(); + } catch (error) { + // Keep error logging for actual fetch failures + console.error('Fetch error:', error); + const store = Alpine.store('global'); + store.showToast(store.t('connectionLost'), 'error'); + } finally { + this.loading = false; + this.initialLoad = false; // Mark initial load as complete + } + }, + + async performHealthCheck() { + try { + // Get password from global store + const password = Alpine.store('global').webuiPassword; + + // Use lightweight endpoint (no quota fetching) + const { response, newPassword } = await window.utils.request('/api/config', {}, password); + + if (newPassword) Alpine.store('global').webuiPassword = newPassword; + + if (response.ok) { + this.connectionStatus = 'connected'; + // Update devMode from server config + try { + const data = await response.json(); + if (data.config) { + this.devMode = !!data.config.devMode; + } + } catch (e) { /* ignore parse errors */ } + } else { + this.connectionStatus = 'disconnected'; + } + } catch (error) { + console.error('Health check error:', error); + this.connectionStatus = 'disconnected'; + } + }, + + startHealthCheck() { + // Clear existing timer + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + } + + // Setup visibility change listener (only once) + if (!this._healthVisibilitySetup) { + this._healthVisibilitySetup = true; + this._visibilityHandler = () => { + if (document.hidden) { + // Tab hidden - stop health checks + this.stopHealthCheck(); + } else { + // Tab visible - restart health checks + this.startHealthCheck(); + } + }; + document.addEventListener('visibilitychange', this._visibilityHandler); + } + + // Perform immediate health check + this.performHealthCheck(); + + // Schedule regular health checks every 15 seconds + this.healthCheckTimer = setInterval(() => { + // Only perform health check if tab is visible + if (!document.hidden) { + this.performHealthCheck(); + } + }, 15000); + }, + + stopHealthCheck() { + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = null; + } + }, + + computeQuotaRows() { + const models = this.models || []; + const rows = []; + const showExhausted = Alpine.store('settings')?.showExhausted ?? true; + + models.forEach(modelId => { + // Config + const config = this.modelConfig[modelId] || {}; + const family = this.getModelFamily(modelId); + + // Visibility Logic for Models Page (quotaRows): + // 1. If explicitly hidden via config, ALWAYS hide (clean interface) + // 2. If no config, default 'unknown' families to HIDDEN + // 3. Known families (Claude/Gemini) default to VISIBLE + // Note: To manage hidden models, use Settings → Models tab + let isHidden = config.hidden; + if (isHidden === undefined) { + isHidden = (family === 'other' || family === 'unknown'); + } + + // Models Page: Check settings for visibility + const showHidden = Alpine.store('settings')?.showHiddenModels ?? false; + if (isHidden && !showHidden) return; + + // Filters + if (this.filters.family !== 'all' && this.filters.family !== family) return; + if (this.filters.search) { + const searchLower = this.filters.search.toLowerCase(); + const idMatch = modelId.toLowerCase().includes(searchLower); + if (!idMatch) return; + } + + // Data Collection + const quotaInfo = []; + let minQuota = 100; + let totalQuotaSum = 0; + let validAccountCount = 0; + let minResetTime = null; + let maxEffectiveThreshold = 0; + const globalThreshold = this.globalQuotaThreshold || 0; + + this.accounts.forEach(acc => { + if (acc.enabled === false) return; + if (this.filters.account !== 'all' && acc.email !== this.filters.account) return; + + const limit = acc.limits?.[modelId]; + if (!limit) return; + + const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0; + minQuota = Math.min(minQuota, pct); + + // Accumulate for average + totalQuotaSum += pct; + validAccountCount++; + + if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) { + minResetTime = limit.resetTime; + } + + // Resolve effective threshold: per-model > per-account > global + const accModelThreshold = acc.modelQuotaThresholds?.[modelId]; + const accThreshold = acc.quotaThreshold; + const effective = accModelThreshold ?? accThreshold ?? globalThreshold; + if (effective > maxEffectiveThreshold) { + maxEffectiveThreshold = effective; + } + + // Determine threshold source for display + let thresholdSource = 'global'; + if (accModelThreshold !== undefined) thresholdSource = 'model'; + else if (accThreshold !== undefined) thresholdSource = 'account'; + + quotaInfo.push({ + email: acc.email.split('@')[0], + fullEmail: acc.email, + pct: pct, + resetTime: limit.resetTime, + thresholdPct: Math.round(effective * 100), + thresholdSource + }); + }); + + if (quotaInfo.length === 0) return; + const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0; + + if (!showExhausted && minQuota === 0) return; + + // Check if thresholds vary across accounts + const uniqueThresholds = new Set(quotaInfo.map(q => q.thresholdPct)); + const hasVariedThresholds = uniqueThresholds.size > 1; + + rows.push({ + modelId, + displayName: modelId, // Simplified: no longer using alias + family, + minQuota, + avgQuota, // Added Average Quota + minResetTime, + resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-', + quotaInfo, + pinned: !!config.pinned, + hidden: !!isHidden, // Use computed visibility + activeCount: quotaInfo.filter(q => q.pct > 0).length, + effectiveThresholdPct: Math.round(maxEffectiveThreshold * 100), + hasVariedThresholds + }); + }); + + // Sort: Pinned first, then by selected column + const sortCol = this.filters.sortCol; + const sortAsc = this.filters.sortAsc; + + this.quotaRows = rows.sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + + let valA = a[sortCol]; + let valB = b[sortCol]; + + // Handle nulls (always push to bottom) + if (valA === valB) return 0; + if (valA === null || valA === undefined) return 1; + if (valB === null || valB === undefined) return -1; + + if (typeof valA === 'string' && typeof valB === 'string') { + return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA); + } + + return sortAsc ? valA - valB : valB - valA; + }); + + // Trigger Dashboard Update if active + // Ideally dashboard watches this store. + }, + + setSort(col) { + if (this.filters.sortCol === col) { + this.filters.sortAsc = !this.filters.sortAsc; + } else { + this.filters.sortCol = col; + // Default sort direction: Descending for numbers/stats, Ascending for text/time + if (['avgQuota', 'activeCount'].includes(col)) { + this.filters.sortAsc = false; + } else { + this.filters.sortAsc = true; + } + } + this.computeQuotaRows(); + }, + + getModelFamily(modelId) { + const lower = modelId.toLowerCase(); + if (lower.includes('claude')) return 'claude'; + if (lower.includes('gemini')) return 'gemini'; + return 'other'; + }, + + /** + * Get quota data without filters applied (for Dashboard global charts) + * Returns array of { modelId, family, quotaInfo: [{pct}] } + */ + getUnfilteredQuotaData() { + const models = this.models || []; + const rows = []; + const showHidden = Alpine.store('settings')?.showHiddenModels ?? false; + + models.forEach(modelId => { + const config = this.modelConfig[modelId] || {}; + const family = this.getModelFamily(modelId); + + // Smart visibility (same logic as computeQuotaRows) + let isHidden = config.hidden; + if (isHidden === undefined) { + isHidden = (family === 'other' || family === 'unknown'); + } + if (isHidden && !showHidden) return; + + const quotaInfo = []; + // Use ALL accounts (no account filter) + this.accounts.forEach(acc => { + if (acc.enabled === false) return; + const limit = acc.limits?.[modelId]; + if (!limit) return; + const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0; + quotaInfo.push({ pct }); + }); + + // treat missing quotaInfo as 0%/unknown; still include row + rows.push({ modelId, family, quotaInfo }); + }); + + return rows; + }, + + /** + * Generate placeholder account and model data for UI testing + */ + _generatePlaceholderData() { + const models = [ + 'claude-opus-4-6-thinking', + 'claude-sonnet-4-6', + 'gemini-3.1-pro-high', + 'gemini-3.1-pro-low', + 'gemini-3-flash', + 'gemini-3.1-flash-lite' + ]; + + const tiers = ['ultra', 'pro', 'pro', 'free']; + const names = ['alice', 'bob', 'charlie', 'diana']; + const domains = ['workspace.dev', 'company.io', 'example.org', 'test.net']; + + const accounts = names.map((name, i) => { + const email = `${name}@${domains[i]}`; + const tier = tiers[i]; + + // Generate varied quota per model per account + const limits = {}; + models.forEach((modelId, mi) => { + // Create a deterministic but varied fraction + const seed = ((i * 7 + mi * 13) % 100); + const fraction = seed < 10 ? 0 : seed / 100; + const resetTime = fraction === 0 + ? new Date(Date.now() + (30 + i * 15) * 60000).toISOString() + : null; + limits[modelId] = { + remaining: Math.round(fraction * 100) + '%', + remainingFraction: fraction, + resetTime + }; + }); + + return { + email, + status: i === 3 ? 'invalid' : 'ok', + error: i === 3 ? 'Token expired' : null, + source: i === 0 ? 'database' : 'oauth', + enabled: i !== 2 ? true : false, + projectId: `proj-${name}-${1000 + i}`, + isInvalid: i === 3, + invalidReason: i === 3 ? 'Token expired' : null, + lastUsed: new Date(Date.now() - i * 3600000).toISOString(), + modelRateLimits: {}, + quotaThreshold: i === 1 ? 0.15 : undefined, + modelQuotaThresholds: i === 0 ? { 'claude-opus-4-6-thinking': 0.25 } : {}, + subscription: { tier, projectId: `proj-${name}-${1000 + i}`, detectedAt: Date.now() }, + limits + }; + }); + + return { accounts, models }; + }, + + /** + * Enable or disable placeholder data injection + */ + setPlaceholderMode(enabled, includeReal) { + this.placeholderMode = enabled; + this.placeholderIncludeReal = includeReal; + + // Persist to settings store + const settings = Alpine.store('settings'); + if (settings) { + settings.placeholderMode = enabled; + settings.placeholderIncludeReal = includeReal; + settings.saveSettings(true); + } + + if (enabled) { + // Stash real data + this._realAccounts = [...this.accounts]; + this._realModels = [...this.models]; + + const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData(); + + if (includeReal && this._realAccounts.length > 0) { + // Merge: real accounts first, then placeholders + this.accounts = [...this._realAccounts, ...fakeAccounts]; + // Union of models + const modelSet = new Set([...this._realModels, ...fakeModels]); + this.models = Array.from(modelSet).sort(); + } else { + this.accounts = fakeAccounts; + this.models = fakeModels; + } + } else { + // Restore real data + if (this._realAccounts !== null) { + this.accounts = this._realAccounts; + this._realAccounts = null; + } + if (this._realModels !== null) { + this.models = this._realModels; + this._realModels = null; + } + } + + this.computeQuotaRows(); + } + }); +}); diff --git a/public/js/settings-store.js b/public/js/settings-store.js new file mode 100755 index 0000000000000000000000000000000000000000..1bf0510c45a0547cdf74810a0ab7f0892736981f --- /dev/null +++ b/public/js/settings-store.js @@ -0,0 +1,76 @@ +/** + * Settings Store + */ +document.addEventListener('alpine:init', () => { + Alpine.store('settings', { + refreshInterval: 60, + logLimit: 2000, + showExhausted: true, + showHiddenModels: false, + showAllAccounts: false, + showConfigWarning: true, + compact: false, + redactMode: false, + placeholderMode: false, + placeholderIncludeReal: true, + debugLogging: true, + logExport: true, + healthInspector: true, + healthInspectorOpen: false, + port: 8080, // Display only + + init() { + this.loadSettings(); + }, + + // Call this method when toggling settings in the UI + toggle(key) { + if (this.hasOwnProperty(key) && typeof this[key] === 'boolean') { + this[key] = !this[key]; + this.saveSettings(true); + } + }, + + loadSettings() { + const saved = localStorage.getItem('antigravity_settings'); + if (saved) { + const parsed = JSON.parse(saved); + Object.keys(parsed).forEach(k => { + // Only load keys that exist in our default state (safety) + if (this.hasOwnProperty(k)) this[k] = parsed[k]; + }); + } + }, + + saveSettings(silent = false) { + const toSave = { + refreshInterval: this.refreshInterval, + logLimit: this.logLimit, + showExhausted: this.showExhausted, + showHiddenModels: this.showHiddenModels, + showAllAccounts: this.showAllAccounts, + showConfigWarning: this.showConfigWarning, + compact: this.compact, + redactMode: this.redactMode, + placeholderMode: this.placeholderMode, + placeholderIncludeReal: this.placeholderIncludeReal, + debugLogging: this.debugLogging, + logExport: this.logExport, + healthInspector: this.healthInspector, + healthInspectorOpen: this.healthInspectorOpen + }; + localStorage.setItem('antigravity_settings', JSON.stringify(toSave)); + + if (!silent) { + const store = Alpine.store('global'); + store.showToast(store.t('configSaved'), 'success'); + } + + // Trigger updates + document.dispatchEvent(new CustomEvent('refresh-interval-changed')); + if (Alpine.store('data')) { + Alpine.store('data').computeQuotaRows(); + } + } + }); +}); diff --git a/public/js/store.js b/public/js/store.js new file mode 100755 index 0000000000000000000000000000000000000000..7e45dfbc54f384e9e28fa5ca52c61f410465f076 --- /dev/null +++ b/public/js/store.js @@ -0,0 +1,119 @@ +/** + * Global Store for Antigravity Console + * Handles Translations, Toasts, and Shared Config + */ + +document.addEventListener('alpine:init', () => { + Alpine.store('global', { + init() { + // Hash-based routing + const validTabs = ['dashboard', 'models', 'accounts', 'logs', 'settings']; + const validSettingsTabs = ['ui', 'claude', 'models', 'server']; + const getHash = () => window.location.hash.substring(1); + + const parseHash = (hash) => { + const [tab, subtab] = hash.split('/'); + return { tab, subtab }; + }; + + // 1. Initial load from hash + const { tab: initialTab, subtab: initialSubtab } = parseHash(getHash()); + if (validTabs.includes(initialTab)) { + this.activeTab = initialTab; + if (initialTab === 'settings' && validSettingsTabs.includes(initialSubtab)) { + this.settingsTab = initialSubtab; + } + } + + // 2. Sync State -> URL + Alpine.effect(() => { + if (!validTabs.includes(this.activeTab)) return; + let target = this.activeTab; + if (this.activeTab === 'settings' && this.settingsTab !== 'ui') { + target = `settings/${this.settingsTab}`; + } + if (getHash() !== target) { + window.location.hash = target; + } + }); + + // 3. Sync URL -> State (Back/Forward buttons) + window.addEventListener('hashchange', () => { + const { tab, subtab } = parseHash(getHash()); + if (validTabs.includes(tab)) { + if (this.activeTab !== tab) { + this.activeTab = tab; + } + if (tab === 'settings') { + this.settingsTab = validSettingsTabs.includes(subtab) ? subtab : 'ui'; + } + } + }); + + // 4. Fetch version from API + this.fetchVersion(); + }, + + async fetchVersion() { + try { + const response = await fetch('/api/config'); + if (response.ok) { + const data = await response.json(); + if (data.version) { + this.version = data.version; + } + // Update maxAccounts in data store + if (data.config && typeof data.config.maxAccounts === 'number') { + Alpine.store('data').maxAccounts = data.config.maxAccounts; + } + } + } catch (error) { + console.debug('Could not fetch version:', error); + } + }, + + // App State + version: '1.0.0', + activeTab: 'dashboard', + settingsTab: 'ui', + webuiPassword: localStorage.getItem('antigravity_webui_password') || '', + + // i18n + lang: localStorage.getItem('app_lang') || 'en', + translations: window.translations || {}, + + // Toast Messages + toast: null, + + // OAuth Progress + oauthProgress: { + active: false, + current: 0, + max: 60, + cancel: null + }, + + t(key, params = {}) { + let str = this.translations[this.lang][key] || key; + if (typeof str === 'string') { + Object.keys(params).forEach(p => { + str = str.replace(`{${p}}`, params[p]); + }); + } + return str; + }, + + setLang(l) { + this.lang = l; + localStorage.setItem('app_lang', l); + }, + + showToast(message, type = 'info') { + const id = Date.now(); + this.toast = { message, type, id }; + setTimeout(() => { + if (this.toast && this.toast.id === id) this.toast = null; + }, 3000); + } + }); +}); diff --git a/public/js/translations/en.js b/public/js/translations/en.js new file mode 100755 index 0000000000000000000000000000000000000000..febc4b62979049001b56382efdc4b3951064aeaa --- /dev/null +++ b/public/js/translations/en.js @@ -0,0 +1,469 @@ +/** + * English Translations + */ +window.translations = window.translations || {}; +window.translations.en = { + dashboard: "Dashboard", + models: "Models", + accounts: "Accounts", + logs: "Logs", + settings: "Settings", + online: "ONLINE", + offline: "OFFLINE", + totalAccounts: "TOTAL ACCOUNTS", + active: "ACTIVE", + operational: "Operational", + rateLimited: "RATE LIMITED", + quotasDepleted: "{count}/{total} Quotas Depleted", + quotasDepletedTitle: "QUOTAS DEPLETED", + outOfTracked: "Out of {total} Tracked", + cooldown: "Cooldown", + searchPlaceholder: "Search models...", + allAccounts: "All Accounts", + stat: "STAT", + modelIdentity: "MODEL IDENTITY", + globalQuota: "GLOBAL QUOTA", + nextReset: "NEXT RESET", + distribution: "ACCOUNT DISTRIBUTION", + systemConfig: "System Configuration", + language: "Language", + pollingInterval: "Polling Interval", + maxDisplayLogs: "Max Displayed Logs", + showExhausted: "Show Exhausted Models", + showExhaustedDesc: "Display models even if they have 0% remaining quota.", + compactMode: "Compact Mode", + compactModeDesc: "Reduce padding in tables for higher information density.", + showConfigWarning: "Show Config Warning", + showConfigWarningDesc: "Display a warning on Dashboard when Claude CLI config doesn't match preset.", + saveChanges: "Save Changes", + autoScroll: "Auto-scroll", + clearLogs: "Clear Logs", + accountManagement: "Account Management", + manageTokens: "Manage Google Account tokens and authorization states", + addAccount: "Add Account", + status: "STATUS", + enabled: "ENABLED", + health: "STATUS", + accountEmail: "ACCOUNT (EMAIL)", + source: "SOURCE", + projectId: "PROJECT ID", + sessionState: "SESSION STATE", + operations: "OPERATIONS", + delete: "Delete", + fix: "Fix", + confirmDelete: "Are you sure you want to remove this account?", + cannotDeleteDatabase: "Cannot delete: This account is from Antigravity database (read-only)", + connectGoogle: "Connect Google Account", + reauthenticated: "re-authenticated", + added: "added", + successfully: "successfully", + accountAddedSuccess: "Account added successfully", + accountReauthSuccess: "Account re-authenticated successfully", + failedToGetAuthUrl: "Failed to get auth URL", + failedToStartOAuth: "Failed to start OAuth flow", + oauthInProgress: "OAuth in progress. Please complete authentication in the popup window...", + family: "Family", + model: "Model", + activeSuffix: "Active", + // Tabs + tabInterface: "Interface", + tabClaude: "Claude CLI", + tabModels: "Models", + tabServer: "Server Settings", + // Dashboard + linkedAccounts: "Linked Accounts", + noSignal: "NO SIGNAL DETECTED", + establishingUplink: "ESTABLISHING UPLINK...", + goToAccounts: "Go to Accounts", + // Settings - Models + modelsDesc: "Configure model visibility, pinning, and request routing.", + modelsPageDesc: "Real-time quota and status for all available models.", + showHidden: "Show Hidden Models", + hideHidden: "Hide Hidden Models", + hiddenOn: "Hidden: ON", + hiddenOff: "Hidden: OFF", + modelId: "Model ID", + actions: "Actions", + pinToTop: "Pin to top", + toggleVisibility: "Toggle Visibility", + noModels: "NO MODELS DETECTED", + modelMappingHint: "Server-side model routing. Claude Code users: see 'Claude CLI' tab for client-side setup.", + modelMapping: "Mapping (Target Model ID)", + // Settings - Claude + proxyConnection: "Proxy Connection", + modelSelection: "Model Selection", + defaultModelAliases: "DEFAULT MODEL ALIASES", + opusAlias: "Opus Alias", + sonnetAlias: "Sonnet Alias", + haikuAlias: "Haiku Alias", + claudeSettingsAlertPrefix: "Settings below directly modify", + claudeSettingsAlertSuffix: "Restart Claude CLI to apply.", + applyToClaude: "Apply to Claude CLI", + // Presets + configPresets: "Configuration Presets", + saveAsPreset: "Save as Preset", + deletePreset: "Delete Preset", + loadPreset: "Load preset into form", + load: "Load", + presetHint: "Select a preset to load it. Click \"Apply to Claude CLI\" to save changes.", + presetLoaded: "Preset loaded. Click \"Apply to Claude CLI\" to save.", + presetSaved: "Preset saved", + presetDeleted: "Preset deleted", + claudeConfigNotApplied: "Claude CLI not configured", + claudeConfigNotAppliedDesc: "Apply a preset to use the proxy with Claude Code.", + configureNow: "Configure", + unsavedChangesTitle: "Unsaved Changes", + unsavedChangesMessage: "Your current configuration doesn't match any saved preset. If you switch, your current unsaved settings will be lost.", + loadAnyway: "Load Anyway", + savePresetTitle: "Save Preset", + savePresetDesc: "Save the current configuration as a reusable preset.", + presetName: "Preset Name", + presetNamePlaceholder: "e.g., My Work Setup", + savePreset: "Save Preset", + // Server Presets + serverPresets: "Server Presets", + serverPresetsHint: "Select a preset and click the download icon to apply it. Built-in presets (\uD83D\uDD12) cannot be deleted.", + loadServerPreset: "Load server preset", + saveServerPreset: "Save Server Preset", + saveServerPresetDesc: "Save the current server configuration as a reusable preset.", + serverPresetLoaded: "Preset \"{name}\" applied", + serverPresetSaved: "Server preset saved", + serverPresetDeleted: "Server preset deleted", + cannotDeleteBuiltIn: "Cannot delete built-in presets", + failedToLoadServerPreset: "Failed to apply preset", + failedToSaveServerPreset: "Failed to save preset", + editServerPreset: "Edit Preset", + editServerPresetDesc: "Update the preset name or description.", + editPreset: "Edit preset", + serverPresetUpdated: "Preset updated", + failedToEditServerPreset: "Failed to update preset", + failedToDeleteServerPreset: "Failed to delete preset", + // Server Preset Descriptions + presetDefaultDesc: "Balanced settings for small account pools. Uses hybrid strategy with moderate retries and cooldowns. Best for most users with 3-5 accounts.", + presetManyAccountsDesc: "Optimized for large account pools (10+). Faster rotation with aggressive retries, shorter cooldowns, and higher quota protection. Maximizes throughput across many accounts.", + presetConservativeDesc: "Prioritizes reliability over speed. Uses sticky strategy for cache continuity, longer retries/cooldowns, and 20% quota protection. Best when stability matters most.", + presetDescPlaceholder: "Description (optional)", + presetPreviewLabel: "Preview Config", + presetPreviewHide: "Hide Preview", + editPresetConfig: "Edit Config", + presetConfigSaved: "Preset config updated", + invalidJson: "Invalid JSON", + failedToSavePresetConfig: "Failed to update preset config", + presetConfigValidationError: "Please fix validation errors before saving", + // Settings - Server + port: "Port", + uiVersion: "UI Version", + debugMode: "Debug Mode", + environment: "Environment", + serverReadOnly: "Settings managed via config.json. Restart server to apply changes.", + advancedSettings: "Advanced Settings", + reloadConfigTitle: "Reload Account Config", + reloadConfigDesc: "Force reload accounts.json from disk", + reload: "Reload", + // Config Specific + primaryModel: "Primary Model", + subAgentModel: "Sub-agent Model", + advancedOverrides: "Default Model Overrides", + opusModel: "Opus Model", + sonnetModel: "Sonnet Model", + haikuModel: "Haiku Model", + authToken: "Auth Token", + saveConfig: "Save to Claude CLI settings", + envVar: "Env", + // New Keys + systemName: "ANTIGRAVITY", + systemDesc: "CLAUDE PROXY SYSTEM", + connectGoogleDesc: "Connect a Google Workspace account to increase your API quota limit. The account will be used to proxy Claude requests via Antigravity.", + useCliCommand: "Use CLI Command", + close: "Close", + requestVolume: "Request Volume", + filter: "Filter", + all: "All", + none: "None", + noDataTracked: "No data tracked yet", + selectFamilies: "Select families to display", + selectModels: "Select models to display", + noLogsMatch: "No logs match filter", + connecting: "CONNECTING", + main: "Main", + system: "System", + refreshData: "Refresh Data", + connectionLost: "Connection Lost", + lastUpdated: "Last Updated", + grepLogs: "grep logs...", + noMatchingModels: "No matching models", + typeToSearch: "Type to search or select...", + or: "OR", + refreshingAccount: "Refreshing {email}...", + refreshedAccount: "Refreshed {email}", + refreshFailed: "Refresh failed", + accountToggled: "Account {email} {status}", + toggleFailed: "Toggle failed", + reauthenticating: "Re-authenticating {email}...", + authUrlFailed: "Failed to get auth URL", + deletedAccount: "Deleted {email}", + deleteFailed: "Delete failed", + accountsReloaded: "Accounts reloaded", + reloadFailed: "Reload failed", + claudeConfigSaved: "Claude configuration saved", + claudeConfigRestored: "Claude CLI restored to defaults", + saveConfigFailed: "Failed to save configuration", + restoreConfigFailed: "Failed to restore configuration", + restoreDefault: "Restore Default", + confirmRestoreTitle: "Confirm Restore", + confirmRestoreMessage: "Are you sure you want to restore Claude CLI to default settings? This will remove proxy configuration.", + confirmRestore: "Confirm Restore", + claudeActive: "Claude Active", + claudeEmpty: "Claude Empty", + geminiActive: "Gemini Active", + geminiEmpty: "Gemini Empty", + synced: "SYNCED", + syncing: "SYNCING...", + // Time range labels + last1Hour: "Last 1H", + last6Hours: "Last 6H", + last24Hours: "Last 24H", + last7Days: "Last 7D", + allTime: "All Time", + groupBy: "Group By", + // Additional + reloading: "Reloading...", + reloaded: "Reloaded", + lines: "lines", + enabledSeeLogs: "Enabled (See Logs)", + production: "Production", + configSaved: "Configuration Saved", + enterPassword: "Enter Web UI Password:", + ready: "READY", + depleted: "Depleted", + timeH: "H", + timeM: "M", + familyClaude: "Claude", + familyGemini: "Gemini", + familyOther: "Other", + enabledStatus: "enabled", + disabledStatus: "disabled", + logLevelInfo: "INFO", + logLevelSuccess: "SUCCESS", + logLevelWarn: "WARN", + logLevelError: "ERR", + totalColon: "Total:", + todayColon: "Today:", + hour1Colon: "1H:", + frequentModels: "Frequent", + smartTitle: "Auto-select top 5 most used models (24h)", + activeCount: "{count} Active", + allCaps: "ALL", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + modelMapping: "Mapping (Target Model ID)", + systemInfo: "System Information", + refresh: "Refresh", + runtimeConfig: "Runtime Configuration", + debugDesc: "Enable detailed logging (See Logs tab)", + networkRetry: "Network Retry Settings", + maxRetries: "Max Retries", + retryBaseDelay: "Retry Base Delay (ms)", + retryMaxDelay: "Retry Max Delay (ms)", + persistentSessions: "Persistent Sessions", + persistTokenDesc: "Save OAuth sessions to disk for faster restarts", + rateLimiting: "Account Rate Limiting & Timeouts", + defaultCooldown: "Default Cooldown", + defaultCooldownDesc: "Fallback cooldown when API doesn't provide a reset time.", + maxWaitThreshold: "Max Wait Before Error", + maxWaitDesc: "If all accounts are rate-limited longer than this, error immediately instead of waiting.", + // Quota Protection + quotaProtection: "Quota Protection", + minimumQuotaLevel: "Minimum Quota Level", + minimumQuotaLevelDesc: "Switch accounts when quota drops below this level. Per-account overrides take priority.", + quotaDisabled: "Disabled", + // Error Handling Tuning + errorHandlingTuning: "Error Handling Tuning", + rateLimitDedupWindow: "Rate Limit Dedup Window", + rateLimitDedupWindowDesc: "Prevents concurrent retry storms when multiple requests hit rate limits simultaneously.", + maxConsecutiveFailures: "Max Consecutive Failures", + maxConsecutiveFailuresDesc: "Number of consecutive failures before applying extended cooldown to an account.", + extendedCooldown: "Extended Cooldown", + extendedCooldownDesc: "Cooldown duration applied after max consecutive failures reached.", + maxCapacityRetries: "Max Capacity Retries", + maxCapacityRetriesDesc: "Maximum retries for capacity exhaustion before switching accounts.", + saveConfigServer: "Save Configuration", + serverRestartAlert: "Changes saved to {path}. Restart server to apply some settings.", + changePassword: "Change WebUI Password", + changePasswordDesc: "Update the password for accessing this dashboard", + currentPassword: "Current Password", + newPassword: "New Password", + confirmNewPassword: "Confirm New Password", + passwordEmptyDesc: "Leave empty if no password set", + passwordLengthDesc: "At least 6 characters", + passwordConfirmDesc: "Re-enter new password", + cancel: "Cancel", + passwordsNotMatch: "Passwords do not match", + passwordTooShort: "Password must be at least 6 characters", + // Dashboard drill-down + clickToViewAllAccounts: "Click to view all accounts", + clickToViewModels: "Click to view Models page", + clickToViewLimitedAccounts: "Click to view rate-limited accounts", + clickToFilterClaude: "Click to filter Claude models", + clickToFilterGemini: "Click to filter Gemini models", + // Accounts page + searchAccounts: "Search accounts...", + noAccountsYet: "No Accounts Yet", + noAccountsDesc: "Get started by adding a Google account via OAuth, or use the CLI command to import credentials.", + addFirstAccount: "Add Your First Account", + noSearchResults: "No accounts match your search", + clearSearch: "Clear Search", + disabledAccountsNote: "Disabled accounts will not be used for request routing but remain in the configuration. Dashboard statistics only include enabled accounts.", + dangerousOperation: "⚠️ Dangerous Operation", + confirmDeletePrompt: "Are you sure you want to delete account", + deleteWarning: "⚠️ This action cannot be undone. All configuration and historical records will be permanently deleted.", + // OAuth progress + oauthWaiting: "Waiting for OAuth authorization...", + oauthWaitingDesc: "Please complete the authentication in the popup window. This may take up to 2 minutes.", + oauthCancelled: "OAuth authorization cancelled", + oauthTimeout: "⏱️ OAuth authorization timed out. Please try again.", + oauthWindowClosed: "OAuth window was closed. Authorization may be incomplete.", + cancelOAuth: "Cancel", + // MCP CLI & Gemini 1M + mcpCliExperimental: "Experimental MCP CLI", + mcpCliDesc: "Enables experimental MCP integration for reliable tool usage with reduced context consumption.", + gemini1mMode: "Gemini 1M Context Mode", + gemini1mDesc: "Appends [1m] suffix to Gemini models for 1M context window support.", + gemini1mWarning: "⚠ Large context may reduce Gemini performance.", + clickToSet: "Click to configure...", + none: "None", + // Quota Distribution + quotaDistribution: "Quota Distribution", + resetsIn: "Resets in {time}", + noQuotaData: "No quota data available for this account yet.", + // Manual OAuth Mode + manualMode: "Manual Mode", + manualModeDesc: "(for environments where callback cannot reach)", + authLinkLabel: "Authorization Link:", + linkCopied: "Link copied to clipboard", + pasteCallbackLabel: "Paste callback URL or code:", + pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... or 4/0xxx...", + completeAuth: "Complete Authorization", + authFailed: "Authorization failed", + // Import/Export + export: "Export", + import: "Import", + exportAccounts: "Export Accounts", + importAccounts: "Import Accounts", + exportSuccess: "Exported {count} accounts", + exportFailed: "Export failed", + importSuccess: "Import complete:", + importFailed: "Import failed", + // UI Elements + pageTitle: "Antigravity Console", + live: "Live", + tier: "Tier", + quota: "Quota", + tierUltra: "Ultra", + tierPro: "Pro", + tierFree: "Free", + menu: "Menu", + github: "GitHub", + noData: "No data", + // Error Messages + operationFailed: "Operation failed", + unknownError: "Unknown error", + presetNameRequired: "Preset name is required", + saveFailed: "Save failed", + failedToSavePreset: "Failed to save preset", + noPresetSelected: "No preset selected", + deletePresetConfirm: "Delete preset \"{name}\"?", + failedToDeletePreset: "Failed to delete preset", + failedToChangePassword: "Failed to change password", + passwordChangedSuccess: "Password changed successfully", + debugModeToggled: "Debug mode {status}", + tokenCacheToggled: "Token cache {status}", + failedToUpdateTokenCache: "Failed to update token cache", + failedToUpdateDebugMode: "Failed to update debug mode", + failedToRefreshAccount: "Failed to refresh account", + failedToDeleteAccount: "Failed to delete account", + failedToReloadAccounts: "Failed to reload accounts", + failedToUpdateModelConfig: "Failed to update model config", + fieldUpdated: "{displayName} updated to {value}", + failedToUpdateField: "Failed to update {displayName}", + // Account Selection Strategy + accountSelectionStrategy: "Account Selection Strategy", + selectionStrategy: "Selection Strategy", + strategyStickyLabel: "Sticky (Cache Optimized)", + strategyRoundRobinLabel: "Round Robin (Load Balanced)", + strategyHybridLabel: "Hybrid (Smart Distribution)", + strategyStickyDesc: "Stays on same account until rate-limited. Best for prompt caching.", + strategyRoundRobinDesc: "Rotates to next account on every request. Maximum throughput.", + strategyHybridDesc: "Smart selection based on health, tokens, and freshness.", + strategyUpdated: "Strategy updated to: {strategy}", + failedToUpdateStrategy: "Failed to update strategy", + invalidStrategy: "Invalid strategy selected", + // Validation Messages + mustBeValidNumber: "{fieldName} must be a valid number", + mustBeAtLeast: "{fieldName} must be at least {min}", + mustBeAtMost: "{fieldName} must be at most {max}", + cannotBeEmpty: "{fieldName} cannot be empty", + mustBeTrueOrFalse: "Value must be true or false", + // Mode Toggle (Proxy/Paid) + connectionMode: "Connection Mode", + proxyMode: "Proxy Mode", + paidMode: "Paid Mode", + usingLocalProxy: "Using local proxy server (localhost:{port})", + usingOfficialApi: "Using official Anthropic API (requires subscription)", + paidModeTitle: "Claude CLI is using the official Anthropic API", + paidModeDesc: "All proxy configuration has been removed. Claude CLI uses your Anthropic subscription directly.", + paidModeHint: "Switch to Proxy mode to configure model routing and presets.", + modeToggleFailed: "Failed to switch mode", + // Screenshot Mode + screenshotMode: "Screenshot Mode", + screenshotModeDesc: "Redact account info for safe screen sharing.", + // Developer Mode + devMode: "Developer Mode", + devModeDesc: "Enables debug logging, log export, and strategy health inspector.", + devModeToggled: "Developer mode {status}", + failedToUpdateDevMode: "Failed to update developer mode", + exportLogs: "Export Logs", + strategyHealthInspector: "Strategy Health Inspector", + healthScore: "Health Score", + tokenBucket: "Tokens", + consecutiveFailures: "Failures", + refreshHealthData: "Refresh", + healthNotAvailable: "Health tracking only available with hybrid strategy.", + // Placeholder Data + debugLogging: "Debug Logging", + debugLoggingDesc: "Show verbose debug messages in the log viewer.", + logExportToggle: "Log Export", + logExportToggleDesc: "Show export button in the log viewer toolbar.", + healthInspectorToggle: "Health Inspector", + healthInspectorToggleDesc: "Show strategy health inspector in the accounts view.", + placeholderData: "Placeholder Data", + placeholderDataDesc: "Inject dummy accounts and quotas for UI testing.", + includeRealAccounts: "Include Real Accounts", + includeRealAccountsDesc: "Show real accounts alongside placeholder data.", + // Server Preset Config Editor - new fields + switchAccountDelay: "Switch Account Delay", + capacityBackoffTiers: "Capacity Backoff Tiers", + scoringWeights: "Scoring Weights", + healthScoreSection: "Health Score", + tokenBucketSection: "Token Bucket", + quotaAwarenessSection: "Quota Awareness", + hsInitial: "Initial Score", + hsSuccessReward: "Success Reward", + hsRateLimitPenalty: "Rate Limit Penalty", + hsFailurePenalty: "Failure Penalty", + hsRecoveryPerHour: "Recovery/Hour", + hsMinUsable: "Min Usable Score", + hsMaxScore: "Max Score", + tbMaxTokens: "Max Tokens", + tbTokensPerMinute: "Tokens/Minute", + tbInitialTokens: "Initial Tokens", + qLowThreshold: "Low Threshold", + qCriticalThreshold: "Critical Threshold", + qStaleMs: "Stale Data Timeout", + weightHealth: "Health Weight", + weightTokens: "Tokens Weight", + weightQuota: "Quota Weight", + weightLru: "LRU Weight", + // Model Dropdown + clearSelection: "Clear selection", +}; diff --git a/public/js/translations/id.js b/public/js/translations/id.js new file mode 100755 index 0000000000000000000000000000000000000000..814507c7bb569fed51fc58ab7a88ced4454b6012 --- /dev/null +++ b/public/js/translations/id.js @@ -0,0 +1,513 @@ +/** + * Indonesian (Bahasa Indonesia) Translations + * + * Panduan Terminologi IT: + * - Istilah teknis umum tetap dalam bahasa Inggris (OAuth, API, token, proxy, debug, cache, dll.) + * - Istilah UI umum diterjemahkan (Simpan, Hapus, Batal, dll.) + * - Pesan error dan status menggunakan campuran yang konsisten + */ +window.translations = window.translations || {}; +window.translations.id = { + // Navigation + dashboard: "Dashboard", + models: "Model", + accounts: "Akun", + logs: "Log", + settings: "Pengaturan", + + // Status + online: "ONLINE", + offline: "OFFLINE", + totalAccounts: "TOTAL AKUN", + active: "AKTIF", + operational: "Berjalan Normal", + rateLimited: "DIBATASI", + quotasDepleted: "{count}/{total} Kuota Habis", + quotasDepletedTitle: "KUOTA HABIS", + outOfTracked: "Dari {total} Terpantau", + cooldown: "Cooldown", + + // Search & Filters + searchPlaceholder: "Cari model...", + allAccounts: "Semua Akun", + stat: "STATUS", + modelIdentity: "IDENTITAS MODEL", + globalQuota: "KUOTA GLOBAL", + nextReset: "RESET BERIKUTNYA", + distribution: "DISTRIBUSI AKUN", + + // System Configuration + systemConfig: "Konfigurasi Sistem", + language: "Bahasa", + pollingInterval: "Interval Polling", + maxDisplayLogs: "Maks. Log Ditampilkan", + showExhausted: "Tampilkan Model Habis", + showExhaustedDesc: "Tampilkan model meskipun kuotanya sudah 0%.", + compactMode: "Mode Ringkas", + compactModeDesc: "Tampilan tabel lebih padat untuk informasi lebih banyak.", + showConfigWarning: "Tampilkan Peringatan Konfigurasi", + showConfigWarningDesc: "Tampilkan peringatan di Dashboard saat konfigurasi Claude CLI tidak sesuai preset.", + saveChanges: "Simpan Perubahan", + + // Logs + autoScroll: "Auto-scroll", + clearLogs: "Hapus Log", + + // Account Management + accountManagement: "Kelola Akun", + manageTokens: "Kelola token akun Google dan status otorisasi", + addAccount: "Tambah Akun", + status: "STATUS", + enabled: "AKTIF", + health: "STATUS", + accountEmail: "AKUN (EMAIL)", + source: "SUMBER", + projectId: "PROJECT ID", + sessionState: "STATUS SESI", + operations: "AKSI", + delete: "Hapus", + confirmDelete: "Yakin ingin menghapus akun ini?", + cannotDeleteDatabase: "Tidak bisa dihapus: Akun ini dari database Antigravity (read-only)", + connectGoogle: "Hubungkan Akun Google", + reauthenticated: "diautentikasi ulang", + added: "ditambahkan", + successfully: "berhasil", + accountAddedSuccess: "Akun berhasil ditambahkan", + accountReauthSuccess: "Akun berhasil diautentikasi ulang", + failedToGetAuthUrl: "Gagal mendapatkan URL autentikasi", + failedToStartOAuth: "Gagal memulai proses OAuth", + oauthInProgress: "Proses OAuth sedang berjalan. Silakan selesaikan autentikasi di jendela popup...", + family: "Jenis", + model: "Model", + activeSuffix: "Aktif", + manualReload: "Muat Ulang Konfigurasi", + + // Tabs + tabInterface: "Tampilan", + tabClaude: "Claude CLI", + tabModels: "Model", + tabServer: "Server", + + // Dashboard + linkedAccounts: "Akun Terhubung", + noSignal: "TIDAK ADA SINYAL", + establishingUplink: "MENGHUBUNGKAN...", + goToAccounts: "Lihat Akun", + + // Settings - Models + modelsDesc: "Atur visibilitas model, pin, dan routing request.", + modelsPageDesc: "Kuota dan status real-time untuk semua model yang tersedia.", + showHidden: "Tampilkan Model Tersembunyi", + hideHidden: "Sembunyikan Model Tersembunyi", + hiddenOn: "Tersembunyi: YA", + hiddenOff: "Tersembunyi: TIDAK", + modelId: "ID Model", + actions: "Aksi", + pinToTop: "Pin ke atas", + toggleVisibility: "Tampilkan/Sembunyikan", + noModels: "MODEL TIDAK DITEMUKAN", + modelMappingHint: "Routing model di sisi server. Pengguna Claude Code: lihat tab 'Claude CLI' untuk pengaturan di sisi klien.", + modelMapping: "Mapping (Target Model ID)", + + // Settings - Claude + proxyConnection: "Koneksi Proxy", + modelSelection: "Pilihan Model", + defaultModelAliases: "ALIAS MODEL DEFAULT", + opusAlias: "Alias Opus", + sonnetAlias: "Alias Sonnet", + haikuAlias: "Alias Haiku", + claudeSettingsAlertPrefix: "Pengaturan di bawah langsung mengubah", + claudeSettingsAlertSuffix: "Restart Claude CLI untuk menerapkan.", + applyToClaude: "Simpan ke Claude CLI", + + // Presets + configPresets: "Preset Konfigurasi", + saveAsPreset: "Simpan sebagai Preset", + deletePreset: "Hapus Preset", + loadPreset: "Muat preset", + load: "Muat", + presetHint: "Pilih preset untuk dimuat. Klik \"Simpan ke Claude CLI\" untuk menyimpan.", + presetLoaded: "Preset dimuat. Klik \"Simpan ke Claude CLI\" untuk menyimpan.", + presetSaved: "Preset tersimpan", + presetDeleted: "Preset terhapus", + claudeConfigNotApplied: "Claude CLI belum dikonfigurasi", + claudeConfigNotAppliedDesc: "Terapkan preset untuk menggunakan proxy dengan Claude Code.", + configureNow: "Konfigurasi", + unsavedChangesTitle: "Ada Perubahan Belum Disimpan", + unsavedChangesMessage: "Konfigurasi saat ini tidak cocok dengan preset tersimpan. Jika beralih, perubahan akan hilang.", + loadAnyway: "Tetap Muat", + savePresetTitle: "Simpan Preset", + savePresetDesc: "Simpan konfigurasi saat ini sebagai preset.", + presetName: "Nama Preset", + presetNamePlaceholder: "contoh: Setup Kantor", + savePreset: "Simpan Preset", + // Server Presets + serverPresets: "Preset Server", + serverPresetsHint: "Pilih preset dan klik ikon unduh untuk menerapkan. Preset bawaan (\uD83D\uDD12) tidak bisa dihapus.", + loadServerPreset: "Muat preset server", + saveServerPreset: "Simpan Preset Server", + saveServerPresetDesc: "Simpan konfigurasi server saat ini sebagai preset.", + serverPresetLoaded: "Preset \"{name}\" diterapkan", + serverPresetSaved: "Preset server tersimpan", + serverPresetDeleted: "Preset server terhapus", + cannotDeleteBuiltIn: "Tidak bisa menghapus preset bawaan", + failedToLoadServerPreset: "Gagal menerapkan preset", + failedToSaveServerPreset: "Gagal menyimpan preset", + editServerPreset: "Edit Preset", + editServerPresetDesc: "Perbarui nama atau deskripsi preset.", + editPreset: "Edit preset", + serverPresetUpdated: "Preset diperbarui", + failedToEditServerPreset: "Gagal memperbarui preset", + failedToDeleteServerPreset: "Gagal menghapus preset", + // Server Preset Descriptions + presetDefaultDesc: "Pengaturan seimbang untuk kumpulan akun kecil. Menggunakan strategi hybrid dengan retry dan cooldown moderat. Terbaik untuk sebagian besar pengguna dengan 3-5 akun.", + presetManyAccountsDesc: "Dioptimalkan untuk kumpulan akun besar (10+). Rotasi lebih cepat dengan retry agresif, cooldown lebih pendek, dan perlindungan kuota lebih tinggi. Memaksimalkan throughput di banyak akun.", + presetConservativeDesc: "Mengutamakan keandalan daripada kecepatan. Menggunakan strategi sticky untuk kontinuitas cache, retry/cooldown lebih lama, dan perlindungan kuota 20%. Terbaik saat stabilitas paling penting.", + presetDescPlaceholder: "Deskripsi (opsional)", + presetPreviewLabel: "Pratinjau Konfigurasi", + presetPreviewHide: "Sembunyikan Pratinjau", + editPresetConfig: "Edit Konfigurasi", + presetConfigSaved: "Konfigurasi preset diperbarui", + invalidJson: "JSON tidak valid", + failedToSavePresetConfig: "Gagal memperbarui konfigurasi preset", + presetConfigValidationError: "Perbaiki kesalahan validasi sebelum menyimpan", + + // Settings - Server + port: "Port", + uiVersion: "Versi UI", + debugMode: "Mode Debug", + environment: "Environment", + serverReadOnly: "Pengaturan dikelola melalui config.json. Restart server untuk menerapkan.", + advancedSettings: "Pengaturan Lanjutan", + reloadConfigTitle: "Muat Ulang Konfigurasi Akun", + reloadConfigDesc: "Paksa muat ulang accounts.json dari disk", + reload: "Muat Ulang", + + // Config Specific + primaryModel: "Model Utama", + subAgentModel: "Model Sub-agent", + advancedOverrides: "Timpa Model Default", + opusModel: "Model Opus", + sonnetModel: "Model Sonnet", + haikuModel: "Model Haiku", + authToken: "Token Auth", + saveConfig: "Simpan ke Claude CLI", + envVar: "Env", + + // System + systemName: "ANTIGRAVITY", + systemDesc: "CLAUDE PROXY SYSTEM", + connectGoogleDesc: "Hubungkan akun Google Workspace untuk meningkatkan kuota API. Akun akan digunakan untuk meneruskan request Claude via Antigravity.", + useCliCommand: "Gunakan Perintah CLI", + close: "Tutup", + requestVolume: "Volume Request", + filter: "Filter", + all: "Semua", + none: "Tidak Ada", + noDataTracked: "Belum ada data tercatat", + selectFamilies: "Pilih jenis model untuk ditampilkan", + selectModels: "Pilih model untuk ditampilkan", + noLogsMatch: "Tidak ada log yang sesuai filter", + connecting: "MENGHUBUNGKAN", + main: "Utama", + system: "Sistem", + refreshData: "Refresh Data", + connectionLost: "Koneksi Terputus", + lastUpdated: "Terakhir Diperbarui", + grepLogs: "cari log...", + noMatchingModels: "Tidak ada model yang cocok", + typeToSearch: "Ketik untuk mencari...", + or: "ATAU", + + // Account Operations + refreshingAccount: "Memperbarui {email}...", + refreshedAccount: "{email} berhasil diperbarui", + refreshFailed: "Gagal memperbarui", + accountToggled: "Akun {email} {status}", + toggleFailed: "Gagal mengubah status", + reauthenticating: "Autentikasi ulang {email}...", + authUrlFailed: "Gagal mendapatkan URL autentikasi", + deletedAccount: "{email} berhasil dihapus", + deleteFailed: "Gagal menghapus", + accountsReloaded: "Akun berhasil dimuat ulang", + reloadFailed: "Gagal memuat ulang", + + // Claude Config + claudeConfigSaved: "Konfigurasi Claude tersimpan", + claudeConfigRestored: "Claude CLI dikembalikan ke default", + saveConfigFailed: "Gagal menyimpan konfigurasi", + restoreConfigFailed: "Gagal mengembalikan konfigurasi", + restoreDefault: "Reset ke Default", + confirmRestoreTitle: "Konfirmasi Reset", + confirmRestoreMessage: "Yakin ingin mengembalikan Claude CLI ke pengaturan default? Konfigurasi proxy akan dihapus.", + confirmRestore: "Ya, Reset", + + // Status Labels + claudeActive: "Claude Aktif", + claudeEmpty: "Claude Kosong", + geminiActive: "Gemini Aktif", + geminiEmpty: "Gemini Kosong", + synced: "SINKRON", + syncing: "SINKRONISASI...", + + // Time range labels + last1Hour: "1 Jam Terakhir", + last6Hours: "6 Jam Terakhir", + last24Hours: "24 Jam Terakhir", + last7Days: "7 Hari Terakhir", + allTime: "Semua Waktu", + groupBy: "Kelompokkan", + + // Additional + reloading: "Memuat ulang...", + reloaded: "Dimuat ulang", + lines: "baris", + enabledSeeLogs: "Aktif (Lihat Log)", + production: "Produksi", + configSaved: "Konfigurasi Tersimpan", + enterPassword: "Masukkan Password Web UI:", + ready: "SIAP", + depleted: "Habis", + timeH: "j", + timeM: "m", + familyClaude: "Claude", + familyGemini: "Gemini", + familyOther: "Lainnya", + enabledStatus: "diaktifkan", + disabledStatus: "dinonaktifkan", + logLevelInfo: "INFO", + logLevelSuccess: "OK", + logLevelWarn: "WARN", + logLevelError: "ERROR", + totalColon: "Total:", + todayColon: "Hari Ini:", + hour1Colon: "1j:", + frequentModels: "Sering Dipakai", + smartTitle: "Otomatis pilih 5 model paling sering dipakai (24 jam)", + activeCount: "{count} Aktif", + allCaps: "SEMUA", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + + // System Info + systemInfo: "Informasi Sistem", + refresh: "Refresh", + runtimeConfig: "Konfigurasi Runtime", + debugDesc: "Aktifkan log detail (Lihat tab Log)", + networkRetry: "Pengaturan Retry Jaringan", + maxRetries: "Maks. Retry", + retryBaseDelay: "Jeda Awal Retry (ms)", + retryMaxDelay: "Jeda Maks. Retry (ms)", + persistentSessions: "Sesi Persisten", + persistTokenDesc: "Simpan sesi OAuth ke disk agar startup lebih cepat", + rateLimiting: "Rate Limiting & Timeout Akun", + defaultCooldown: "Cooldown Default", + defaultCooldownDesc: "Cooldown bawaan jika API tidak memberikan waktu reset.", + maxWaitThreshold: "Batas Tunggu Maksimal", + maxWaitDesc: "Jika semua akun terkena rate limit lebih lama dari ini, langsung gagal.", + // Perlindungan Kuota + quotaProtection: "Perlindungan Kuota", + minimumQuotaLevel: "Level Kuota Minimum", + minimumQuotaLevelDesc: "Ganti akun ketika kuota turun di bawah level ini. Pengaturan per-akun lebih diutamakan.", + quotaDisabled: "Nonaktif", + // Error Handling Tuning + errorHandlingTuning: "Penyetelan Penanganan Error", + rateLimitDedupWindow: "Jendela Deduplikasi Rate Limit", + rateLimitDedupWindowDesc: "Mencegah badai retry ketika beberapa permintaan terkena rate limit bersamaan.", + maxConsecutiveFailures: "Maks. Kegagalan Berturut-turut", + maxConsecutiveFailuresDesc: "Jumlah kegagalan berturut-turut sebelum menerapkan cooldown diperpanjang.", + extendedCooldown: "Cooldown Diperpanjang", + extendedCooldownDesc: "Durasi cooldown setelah mencapai maks. kegagalan berturut-turut.", + maxCapacityRetries: "Maks. Retry Kapasitas", + maxCapacityRetriesDesc: "Maksimum retry untuk kehabisan kapasitas sebelum ganti akun.", + saveConfigServer: "Simpan Konfigurasi", + serverRestartAlert: "Tersimpan ke {path}. Restart server untuk menerapkan.", + + // Password + changePassword: "Ubah Password WebUI", + changePasswordDesc: "Ubah password untuk akses dashboard ini", + currentPassword: "Password Saat Ini", + newPassword: "Password Baru", + confirmNewPassword: "Konfirmasi Password Baru", + passwordEmptyDesc: "Kosongkan jika belum ada password", + passwordLengthDesc: "Minimal 6 karakter", + passwordConfirmDesc: "Masukkan ulang password baru", + cancel: "Batal", + passwordsNotMatch: "Password tidak cocok", + passwordTooShort: "Password minimal 6 karakter", + + // Dashboard drill-down + clickToViewAllAccounts: "Klik untuk lihat semua akun", + clickToViewModels: "Klik untuk lihat halaman Model", + clickToViewLimitedAccounts: "Klik untuk lihat akun yang dibatasi", + clickToFilterClaude: "Klik untuk filter model Claude", + clickToFilterGemini: "Klik untuk filter model Gemini", + + // Accounts page + searchAccounts: "Cari akun...", + noAccountsYet: "Belum Ada Akun", + noAccountsDesc: "Mulai dengan menambahkan akun Google via OAuth, atau gunakan perintah CLI untuk import kredensial.", + addFirstAccount: "Tambah Akun Pertama", + noSearchResults: "Tidak ada akun yang cocok", + clearSearch: "Reset Pencarian", + disabledAccountsNote: "Akun nonaktif tidak akan digunakan untuk routing tapi tetap tersimpan di konfigurasi. Statistik dashboard hanya mencakup akun aktif.", + dangerousOperation: "⚠️ Operasi Berbahaya", + confirmDeletePrompt: "Yakin ingin menghapus akun", + deleteWarning: "⚠️ Tindakan ini tidak bisa dibatalkan. Semua pengaturan dan riwayat akan dihapus permanen.", + + // OAuth progress + oauthWaiting: "Menunggu otorisasi OAuth...", + oauthWaitingDesc: "Silakan selesaikan autentikasi di jendela popup. Proses ini bisa memakan waktu hingga 2 menit.", + oauthCancelled: "Otorisasi OAuth dibatalkan", + oauthTimeout: "⏱️ Waktu otorisasi OAuth habis. Silakan coba lagi.", + oauthWindowClosed: "Jendela OAuth ditutup. Otorisasi mungkin tidak lengkap.", + cancelOAuth: "Batal", + + // MCP CLI & Gemini 1M + mcpCliExperimental: "MCP CLI (Eksperimental)", + mcpCliDesc: "Aktifkan integrasi MCP eksperimental untuk penggunaan tool yang lebih stabil dengan konsumsi konteks lebih rendah.", + gemini1mMode: "Mode Konteks Gemini 1M", + gemini1mDesc: "Tambahkan suffix [1m] ke model Gemini untuk context window 1M.", + gemini1mWarning: "⚠ Konteks besar dapat menurunkan performa Gemini-3.", + clickToSet: "Klik untuk atur...", + + // Quota Distribution + quotaDistribution: "Distribusi Kuota", + resetsIn: "Reset dalam {time}", + noQuotaData: "Data kuota belum tersedia untuk akun ini.", + // Manual OAuth Mode + manualMode: "Mode Manual", + manualModeDesc: "(untuk lingkungan di mana callback tidak bisa dicapai)", + authLinkLabel: "Link Otorisasi:", + linkCopied: "Link disalin ke clipboard", + pasteCallbackLabel: "Tempel callback URL atau kode:", + pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... atau 4/0xxx...", + completeAuth: "Selesaikan Otorisasi", + authFailed: "Otorisasi gagal", + // Import/Export + export: "Ekspor", + import: "Impor", + exportAccounts: "Ekspor Akun", + importAccounts: "Impor Akun", + exportSuccess: "Berhasil mengekspor {count} akun", + exportFailed: "Gagal mengekspor", + importSuccess: "Impor selesai:", + importFailed: "Gagal mengimpor", + + // Completed TODOs + pageTitle: "Antigravity Console", + live: "Live", + tier: "Tier", + quota: "Kuota", + tierUltra: "Ultra", + tierPro: "Pro", + tierFree: "Gratis", + menu: "Menu", + github: "GitHub", + noData: "Tidak ada data", + fix: "Perbaiki", + + // Error Messages + operationFailed: "Operasi gagal", + unknownError: "Error tidak diketahui", + presetNameRequired: "Nama preset wajib diisi", + saveFailed: "Gagal menyimpan", + failedToSavePreset: "Gagal menyimpan preset", + noPresetSelected: "Tidak ada preset dipilih", + deletePresetConfirm: "Hapus preset \"{name}\"?", + deleteFailed: "Gagal menghapus", + failedToDeletePreset: "Gagal menghapus preset", + failedToChangePassword: "Gagal mengubah password", + passwordChangedSuccess: "Password berhasil diubah", + debugModeToggled: "Mode debug {status}", + tokenCacheToggled: "Cache token {status}", + failedToUpdateTokenCache: "Gagal memperbarui cache token", + failedToUpdateDebugMode: "Gagal memperbarui mode debug", + failedToRefreshAccount: "Gagal memperbarui akun", + failedToDeleteAccount: "Gagal menghapus akun", + failedToReloadAccounts: "Gagal memuat ulang akun", + failedToUpdateModelConfig: "Gagal memperbarui konfigurasi model", + fieldUpdated: "{displayName} diubah menjadi {value}", + failedToUpdateField: "Gagal memperbarui {displayName}", + + // Validation + mustBeValidNumber: "{fieldName} harus berupa angka valid", + mustBeAtLeast: "{fieldName} minimal {min}", + mustBeAtMost: "{fieldName} maksimal {max}", + cannotBeEmpty: "{fieldName} tidak boleh kosong", + mustBeTrueOrFalse: "Nilai harus true atau false", + + // Account Selection Strategy translations + accountSelectionStrategy: "Strategi Pemilihan Akun", + selectionStrategy: "Strategi Pemilihan", + strategyStickyLabel: "Tetap (Optimisasi Cache)", + strategyRoundRobinLabel: "Bergilir (Load Balanced)", + strategyHybridLabel: "Hibrida (Distribusi Cerdas)", + strategyStickyDesc: "Tetap di akun yang sama hingga terkena rate limit. Terbaik untuk cache prompt.", + strategyRoundRobinDesc: "Berputar ke akun berikutnya setiap permintaan. Throughput maksimum.", + strategyHybridDesc: "Pemilihan cerdas berdasarkan kesehatan, token, dan kesegaran.", + strategyUpdated: "Strategi diubah ke: {strategy}", + failedToUpdateStrategy: "Gagal memperbarui strategi", + invalidStrategy: "Strategi tidak valid dipilih", + // Mode Toggle (Proxy/Paid) + connectionMode: "Mode Koneksi", + proxyMode: "Mode Proxy", + paidMode: "Mode Berbayar", + usingLocalProxy: "Menggunakan server proxy lokal (localhost:{port})", + usingOfficialApi: "Menggunakan API resmi Anthropic (memerlukan langganan)", + paidModeTitle: "Claude CLI menggunakan API resmi Anthropic", + paidModeDesc: "Semua konfigurasi proxy telah dihapus. Claude CLI menggunakan langganan Anthropic Anda secara langsung.", + paidModeHint: "Beralih ke mode Proxy untuk mengonfigurasi routing model dan preset.", + modeToggleFailed: "Gagal beralih mode", + screenshotMode: "Mode Screenshot", + screenshotModeDesc: "Sembunyikan info akun untuk berbagi layar yang aman.", + // Developer Mode + devMode: "Mode Pengembang", + devModeDesc: "Aktifkan log debug, ekspor log, dan inspektur kesehatan strategi.", + devModeToggled: "Mode pengembang {status}", + failedToUpdateDevMode: "Gagal memperbarui mode pengembang", + exportLogs: "Ekspor Log", + strategyHealthInspector: "Inspektur Kesehatan Strategi", + healthScore: "Skor Kesehatan", + tokenBucket: "Token", + consecutiveFailures: "Kegagalan", + refreshHealthData: "Segarkan", + healthNotAvailable: "Pelacakan kesehatan hanya tersedia dengan strategi hybrid.", + // Placeholder Data + debugLogging: "Log Debug", + debugLoggingDesc: "Tampilkan pesan debug terperinci di penampil log.", + logExportToggle: "Ekspor Log", + logExportToggleDesc: "Tampilkan tombol ekspor di toolbar penampil log.", + healthInspectorToggle: "Inspektur Kesehatan", + healthInspectorToggleDesc: "Tampilkan inspektur kesehatan strategi di tampilan akun.", + placeholderData: "Data Placeholder", + placeholderDataDesc: "Masukkan akun dan kuota dummy untuk pengujian UI.", + includeRealAccounts: "Sertakan Akun Asli", + includeRealAccountsDesc: "Tampilkan akun asli bersama data placeholder.", + // Editor Konfigurasi Preset Server - bidang baru + switchAccountDelay: "Penundaan Ganti Akun", + capacityBackoffTiers: "Tingkat Mundur Kapasitas", + scoringWeights: "Bobot Penilaian", + healthScoreSection: "Skor Kesehatan", + tokenBucketSection: "Ember Token", + quotaAwarenessSection: "Kesadaran Kuota", + hsInitial: "Skor Awal", + hsSuccessReward: "Hadiah Sukses", + hsRateLimitPenalty: "Penalti Batas Laju", + hsFailurePenalty: "Penalti Kegagalan", + hsRecoveryPerHour: "Pemulihan/Jam", + hsMinUsable: "Skor Min Dapat Digunakan", + hsMaxScore: "Skor Maksimum", + tbMaxTokens: "Token Maksimum", + tbTokensPerMinute: "Token/Menit", + tbInitialTokens: "Token Awal", + qLowThreshold: "Ambang Rendah", + qCriticalThreshold: "Ambang Kritis", + qStaleMs: "Batas Waktu Data Kedaluwarsa", + weightHealth: "Bobot Kesehatan", + weightTokens: "Bobot Token", + weightQuota: "Bobot Kuota", + weightLru: "Bobot LRU", + // Dropdown Model + clearSelection: "Hapus pilihan", +}; diff --git a/public/js/translations/pt.js b/public/js/translations/pt.js new file mode 100755 index 0000000000000000000000000000000000000000..317a3451b676eeb1694b8a71059502b428922a48 --- /dev/null +++ b/public/js/translations/pt.js @@ -0,0 +1,412 @@ +/** + * Portuguese (PT-BR) Translations + * Based on contribution by Pedro Farias in PR #108 + */ +window.translations = window.translations || {}; +window.translations.pt = { + dashboard: "Painel", + models: "Modelos", + accounts: "Contas", + logs: "Logs", + settings: "Configurações", + online: "ONLINE", + offline: "OFFLINE", + totalAccounts: "TOTAL DE CONTAS", + active: "ATIVO", + operational: "Operacional", + rateLimited: "TAXA LIMITADA", + quotasDepleted: "{count}/{total} Cotas Esgotadas", + quotasDepletedTitle: "COTAS ESGOTADAS", + outOfTracked: "De {total} Rastreados", + cooldown: "Resfriamento", + searchPlaceholder: "Buscar modelos...", + allAccounts: "Todas as Contas", + stat: "ESTATÍSTICA", + modelIdentity: "IDENTIDADE DO MODELO", + globalQuota: "COTA GLOBAL", + nextReset: "PRÓXIMO RESET", + distribution: "DISTRIBUIÇÃO DE CONTAS", + systemConfig: "Configuração do Sistema", + language: "Idioma", + pollingInterval: "Intervalo de Atualização", + maxDisplayLogs: "Máximo de Logs Exibidos", + showExhausted: "Mostrar Modelos Esgotados", + showExhaustedDesc: "Exibir modelos mesmo se tiverem 0% de cota restante.", + compactMode: "Modo Compacto", + compactModeDesc: "Reduzir espaçamento nas tabelas para maior densidade de informação.", + showConfigWarning: "Mostrar Aviso de Configuração", + showConfigWarningDesc: "Exibir aviso no Dashboard quando a configuração do Claude CLI não corresponder ao preset.", + saveChanges: "Salvar Alterações", + autoScroll: "Rolagem Automática", + clearLogs: "Limpar Logs", + accountManagement: "Gerenciamento de Contas", + manageTokens: "Gerenciar tokens e estados de autorização de Contas Google", + addAccount: "Adicionar Conta", + status: "STATUS", + enabled: "HABILITADO", + health: "SAÚDE", + accountEmail: "CONTA (EMAIL)", + source: "FONTE", + projectId: "ID DO PROJETO", + sessionState: "ESTADO DA SESSÃO", + operations: "OPERAÇÕES", + delete: "Excluir", + confirmDelete: "Tem certeza de que deseja remover esta conta?", + cannotDeleteDatabase: "Não é possível excluir: Esta conta é do banco de dados Antigravity (somente leitura)", + connectGoogle: "Conectar Conta Google", + reauthenticated: "reautenticado", + added: "adicionado", + successfully: "com sucesso", + accountAddedSuccess: "Conta adicionada com sucesso", + accountReauthSuccess: "Conta reautenticada com sucesso", + failedToGetAuthUrl: "Falha ao obter URL de autenticação", + failedToStartOAuth: "Falha ao iniciar fluxo OAuth", + oauthInProgress: "OAuth em andamento. Por favor, conclua a autenticação na janela popup...", + family: "Família", + model: "Modelo", + activeSuffix: "Ativo", + // Tabs + tabInterface: "Interface", + tabClaude: "Claude CLI", + tabModels: "Modelos", + tabServer: "Configurações do Servidor", + // Dashboard + linkedAccounts: "Contas Vinculadas", + noSignal: "SEM SINAL DETECTADO", + establishingUplink: "ESTABELECENDO LINK...", + goToAccounts: "Ir para Contas", + // Settings - Models + modelsDesc: "Configurar visibilidade, fixação e roteamento de requisições de modelos.", + modelsPageDesc: "Cota e status em tempo real para todos os modelos disponíveis.", + showHidden: "Mostrar Modelos Ocultos", + modelId: "ID do Modelo", + actions: "Ações", + pinToTop: "Fixar no topo", + toggleVisibility: "Alternar Visibilidade", + noModels: "NENHUM MODELO DETECTADO", + modelMappingHint: "Roteamento de modelo no servidor. Usuários do Claude Code: veja a aba 'Claude CLI' para configuração no cliente.", + modelMapping: "Mapeamento (ID do Modelo Alvo)", + // Settings - Claude + proxyConnection: "Conexão Proxy", + modelSelection: "Seleção de Modelo", + defaultModelAliases: "ALIASES DE MODELO PADRÃO", + opusAlias: "Alias Opus", + sonnetAlias: "Alias Sonnet", + haikuAlias: "Alias Haiku", + claudeSettingsAlertPrefix: "As configurações abaixo modificam diretamente", + claudeSettingsAlertSuffix: "Reinicie o Claude CLI para aplicar.", + applyToClaude: "Aplicar ao Claude CLI", + // Server Presets + serverPresets: "Predefinições do Servidor", + serverPresetsHint: "Selecione uma predefinição e clique no ícone de download para aplicar. Predefinições integradas (\uD83D\uDD12) não podem ser excluídas.", + loadServerPreset: "Carregar predefinição do servidor", + saveServerPreset: "Salvar Predefinição do Servidor", + saveServerPresetDesc: "Salvar a configuração atual do servidor como predefinição reutilizável.", + serverPresetLoaded: "Predefinição \"{name}\" aplicada", + serverPresetSaved: "Predefinição do servidor salva", + serverPresetDeleted: "Predefinição do servidor excluída", + cannotDeleteBuiltIn: "Não é possível excluir predefinições integradas", + failedToLoadServerPreset: "Falha ao aplicar predefinição", + failedToSaveServerPreset: "Falha ao salvar predefinição", + editServerPreset: "Editar Predefinição", + editServerPresetDesc: "Atualize o nome ou a descrição da predefinição.", + editPreset: "Editar predefinição", + serverPresetUpdated: "Predefinição atualizada", + failedToEditServerPreset: "Falha ao atualizar predefinição", + failedToDeleteServerPreset: "Falha ao excluir predefinição", + // Server Preset Descriptions + presetDefaultDesc: "Configurações equilibradas para grupos pequenos de contas. Usa estratégia híbrida com tentativas e cooldowns moderados. Ideal para a maioria dos usuários com 3-5 contas.", + presetManyAccountsDesc: "Otimizado para grupos grandes de contas (10+). Rotação mais rápida com tentativas agressivas, cooldowns mais curtos e maior proteção de cota. Maximiza o throughput em muitas contas.", + presetConservativeDesc: "Prioriza confiabilidade sobre velocidade. Usa estratégia sticky para continuidade de cache, tentativas/cooldowns mais longos e proteção de cota de 20%. Ideal quando a estabilidade é mais importante.", + presetDescPlaceholder: "Descrição (opcional)", + presetPreviewLabel: "Visualizar Config", + presetPreviewHide: "Ocultar Visualização", + editPresetConfig: "Editar Config", + presetConfigSaved: "Configuração do preset atualizada", + invalidJson: "JSON inválido", + failedToSavePresetConfig: "Falha ao atualizar configuração do preset", + presetConfigValidationError: "Corrija os erros de validação antes de salvar", + // Settings - Server + port: "Porta", + uiVersion: "Versão da UI", + debugMode: "Modo de Depuração", + environment: "Ambiente", + serverReadOnly: "Configurações gerenciadas via config.json. Reinicie o servidor para aplicar alterações.", + advancedSettings: "Configurações Avançadas", + reloadConfigTitle: "Recarregar Config de Contas", + reloadConfigDesc: "Forçar recarregamento de accounts.json do disco", + reload: "Recarregar", + // Config Specific + primaryModel: "Modelo Primário", + subAgentModel: "Modelo Sub-agente", + advancedOverrides: "Substituições de Modelo Padrão", + opusModel: "Modelo Opus", + sonnetModel: "Modelo Sonnet", + haikuModel: "Modelo Haiku", + authToken: "Token de Autenticação", + saveConfig: "Salvar nas configurações do Claude CLI", + envVar: "Env", + // New Keys + systemName: "ANTIGRAVITY", + systemDesc: "SISTEMA DE PROXY CLAUDE", + connectGoogleDesc: "Conecte uma conta Google Workspace para aumentar seu limite de cota da API. A conta será usada para fazer proxy das requisições Claude via Antigravity.", + useCliCommand: "Usar Comando CLI", + close: "Fechar", + requestVolume: "Volume de Requisições", + filter: "Filtrar", + all: "Todos", + none: "Nenhum", + noDataTracked: "Nenhum dado rastreado ainda", + selectFamilies: "Selecione famílias para exibir", + selectModels: "Selecione modelos para exibir", + noLogsMatch: "Nenhum log corresponde ao filtro", + connecting: "CONECTANDO", + main: "Principal", + system: "Sistema", + refreshData: "Atualizar Dados", + connectionLost: "Conexão Perdida", + lastUpdated: "Última Atualização", + grepLogs: "grep logs...", + noMatchingModels: "Nenhum modelo correspondente", + typeToSearch: "Digite para buscar ou selecione...", + or: "OU", + refreshingAccount: "Atualizando {email}...", + refreshedAccount: "Atualizado {email}", + refreshFailed: "Falha na atualização", + accountToggled: "Conta {email} {status}", + toggleFailed: "Falha ao alternar", + reauthenticating: "Reautenticando {email}...", + authUrlFailed: "Falha ao obter URL de autenticação", + deletedAccount: "Excluído {email}", + deleteFailed: "Falha ao excluir", + accountsReloaded: "Contas recarregadas", + reloadFailed: "Falha ao recarregar", + claudeConfigSaved: "Configuração do Claude salva", + claudeConfigRestored: "Claude CLI restaurado para o padrão", + saveConfigFailed: "Falha ao salvar configuração", + restoreConfigFailed: "Falha ao restaurar configuração", + restoreDefault: "Restaurar Padrão", + confirmRestoreTitle: "Confirmar Restauração", + confirmRestoreMessage: "Tem certeza de que deseja restaurar o Claude CLI para as configurações padrão? Isso removerá a configuração de proxy.", + confirmRestore: "Confirmar Restauração", + claudeConfigNotApplied: "Claude CLI não configurado", + claudeConfigNotAppliedDesc: "Aplique um preset para usar o proxy com o Claude Code.", + configureNow: "Configurar", + claudeActive: "Claude Ativo", + claudeEmpty: "Claude Vazio", + geminiActive: "Gemini Ativo", + geminiEmpty: "Gemini Vazio", + synced: "SINCRONIZADO", + syncing: "SINCRONIZANDO...", + // Time range labels + last1Hour: "Última 1H", + last6Hours: "Últimas 6H", + last24Hours: "Últimas 24H", + last7Days: "Últimos 7D", + allTime: "Todo o Período", + groupBy: "Agrupar Por", + // Additional + reloading: "Recarregando...", + reloaded: "Recarregado", + lines: "linhas", + enabledSeeLogs: "Habilitado (Ver Logs)", + production: "Produção", + configSaved: "Configuração Salva", + enterPassword: "Digite a Senha da Web UI:", + ready: "PRONTO", + depleted: "Esgotado", + timeH: "H", + timeM: "M", + familyClaude: "Claude", + familyGemini: "Gemini", + familyOther: "Outro", + enabledStatus: "habilitado", + disabledStatus: "desabilitado", + logLevelInfo: "INFO", + logLevelSuccess: "SUCESSO", + logLevelWarn: "AVISO", + logLevelError: "ERRO", + totalColon: "Total:", + todayColon: "Hoje:", + hour1Colon: "1H:", + frequentModels: "Frequentes", + smartTitle: "Auto-selecionar os 5 modelos mais usados (24h)", + activeCount: "{count} Ativos", + allCaps: "TODOS", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + systemInfo: "Informações do Sistema", + refresh: "Atualizar", + runtimeConfig: "Configuração em Tempo de Execução", + debugDesc: "Habilitar log detalhado (Ver aba Logs)", + networkRetry: "Configurações de Retentativa de Rede", + maxRetries: "Máximo de Retentativas", + retryBaseDelay: "Atraso Base de Retentativa (ms)", + retryMaxDelay: "Atraso Máximo de Retentativa (ms)", + persistentSessions: "Sessões Persistentes", + persistTokenDesc: "Salvar sessões OAuth no disco para reinicializações mais rápidas", + rateLimiting: "Limitação de Taxa de Conta & Timeouts", + defaultCooldown: "Tempo de Resfriamento Padrão", + defaultCooldownDesc: "Resfriamento de fallback quando a API não fornece tempo de reset.", + maxWaitThreshold: "Limiar Máximo de Espera (Sticky)", + maxWaitDesc: "Tempo máximo para aguardar uma conta sticky resetar antes de trocar.", + // Proteção de Cota + quotaProtection: "Proteção de Cota", + minimumQuotaLevel: "Nível Mínimo de Cota", + minimumQuotaLevelDesc: "Trocar de conta quando a cota cair abaixo deste nível. Configurações por conta têm prioridade.", + quotaDisabled: "Desativado", + // Ajuste de Tratamento de Erros + errorHandlingTuning: "Ajuste de Tratamento de Erros", + rateLimitDedupWindow: "Janela de Deduplicação de Rate Limit", + rateLimitDedupWindowDesc: "Previne tempestades de retry quando múltiplas requisições atingem rate limits simultaneamente.", + maxConsecutiveFailures: "Máx. Falhas Consecutivas", + maxConsecutiveFailuresDesc: "Número de falhas consecutivas antes de aplicar resfriamento estendido.", + extendedCooldown: "Resfriamento Estendido", + extendedCooldownDesc: "Duração do resfriamento aplicado após atingir máx. de falhas consecutivas.", + maxCapacityRetries: "Máx. Retries de Capacidade", + maxCapacityRetriesDesc: "Máximo de retries para esgotamento de capacidade antes de trocar conta.", + saveConfigServer: "Salvar Configuração", + serverRestartAlert: "Alterações salvas em {path}. Reinicie o servidor para aplicar algumas configurações.", + changePassword: "Alterar Senha da WebUI", + changePasswordDesc: "Atualizar a senha para acessar este painel", + currentPassword: "Senha Atual", + newPassword: "Nova Senha", + confirmNewPassword: "Confirmar Nova Senha", + passwordEmptyDesc: "Deixe em branco se nenhuma senha estiver definida", + passwordLengthDesc: "Pelo menos 6 caracteres", + passwordConfirmDesc: "Redigite a nova senha", + cancel: "Cancelar", + passwordsNotMatch: "Senhas não coincidem", + passwordTooShort: "A senha deve ter pelo menos 6 caracteres", + // Dashboard drill-down + clickToViewAllAccounts: "Clique para ver todas as contas", + clickToViewModels: "Clique para ver a página de Modelos", + clickToViewLimitedAccounts: "Clique para ver contas limitadas", + clickToFilterClaude: "Clique para filtrar modelos Claude", + clickToFilterGemini: "Clique para filtrar modelos Gemini", + // Accounts page + searchAccounts: "Buscar contas...", + noAccountsYet: "Nenhuma Conta Ainda", + noAccountsDesc: "Comece adicionando uma conta Google via OAuth, ou use o comando CLI para importar credenciais.", + addFirstAccount: "Adicione Sua Primeira Conta", + noSearchResults: "Nenhuma conta corresponde à sua busca", + clearSearch: "Limpar Busca", + disabledAccountsNote: "Contas desabilitadas não serão usadas para roteamento de requisições, mas permanecem na configuração. As estatísticas do painel incluem apenas contas habilitadas.", + dangerousOperation: "⚠️ Operação Perigosa", + confirmDeletePrompt: "Tem certeza de que deseja excluir a conta", + deleteWarning: "⚠️ Esta ação não pode ser desfeita. Todas as configurações e registros históricos serão excluídos permanentemente.", + // OAuth progress + oauthWaiting: "Aguardando autorização OAuth...", + oauthWaitingDesc: "Por favor, complete a autenticação na janela popup. Isso pode levar até 2 minutos.", + oauthCancelled: "Autorização OAuth cancelada", + oauthTimeout: "⏱️ Autorização OAuth expirou. Por favor, tente novamente.", + oauthWindowClosed: "A janela OAuth foi fechada. A autorização pode estar incompleta.", + cancelOAuth: "Cancelar", + // MCP CLI & Gemini 1M + mcpCliExperimental: "CLI MCP Experimental", + mcpCliDesc: "Habilita integração MCP experimental para uso confiável de ferramentas com consumo reduzido de contexto.", + gemini1mMode: "Modo de Contexto Gemini 1M", + gemini1mDesc: "Adiciona sufixo [1m] aos modelos Gemini para suporte a janela de contexto de 1M.", + gemini1mWarning: "⚠ Contexto grande pode reduzir o desempenho do Gemini.", + clickToSet: "Clique para configurar...", + none: "Nenhum", + // Quota Distribution + quotaDistribution: "Distribuição de Quota", + resetsIn: "Reseta em {time}", + noQuotaData: "Dados de quota ainda não disponíveis para esta conta.", + // Manual OAuth Mode + manualMode: "Modo Manual", + manualModeDesc: "(para ambientes onde callback não consegue alcançar)", + authLinkLabel: "Link de Autorização:", + linkCopied: "Link copiado para a área de transferência", + pasteCallbackLabel: "Cole a URL de callback ou código:", + pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... ou 4/0xxx...", + completeAuth: "Completar Autorização", + authFailed: "Falha na autorização", + // Import/Export + export: "Exportar", + import: "Importar", + exportAccounts: "Exportar Contas", + importAccounts: "Importar Contas", + exportSuccess: "Exportadas {count} contas", + exportFailed: "Falha ao exportar", + importSuccess: "Importação concluída:", + importFailed: "Falha ao importar", + + // Account Selection Strategy translations + accountSelectionStrategy: "Estratégia de Seleção de Conta", + selectionStrategy: "Estratégia de Seleção", + strategyStickyLabel: "Fixo (Otimizado para Cache)", + strategyRoundRobinLabel: "Rodízio (Balanceamento de Carga)", + strategyHybridLabel: "Híbrido (Distribuição Inteligente)", + strategyStickyDesc: "Permanece na mesma conta até atingir limite. Melhor para cache de prompts.", + strategyRoundRobinDesc: "Alterna para próxima conta a cada requisição. Máximo throughput.", + strategyHybridDesc: "Seleção inteligente baseada em saúde, tokens e frescor.", + strategyUpdated: "Estratégia atualizada para: {strategy}", + failedToUpdateStrategy: "Falha ao atualizar estratégia", + invalidStrategy: "Estratégia inválida selecionada", + // Mode Toggle (Proxy/Paid) + connectionMode: "Modo de Conexão", + proxyMode: "Modo Proxy", + paidMode: "Modo Pago", + usingLocalProxy: "Usando servidor proxy local (localhost:{port})", + usingOfficialApi: "Usando API oficial da Anthropic (requer assinatura)", + paidModeTitle: "Claude CLI está usando a API oficial da Anthropic", + paidModeDesc: "Toda configuração de proxy foi removida. Claude CLI usa sua assinatura Anthropic diretamente.", + paidModeHint: "Mude para modo Proxy para configurar roteamento de modelos e presets.", + modeToggleFailed: "Falha ao alternar modo", + screenshotMode: "Modo Screenshot", + screenshotModeDesc: "Ocultar informações da conta para compartilhamento seguro de tela.", + // Developer Mode + devMode: "Modo Desenvolvedor", + devModeDesc: "Ativa logs de debug, exportação de logs e inspetor de saúde da estratégia.", + devModeToggled: "Modo desenvolvedor {status}", + failedToUpdateDevMode: "Falha ao atualizar modo desenvolvedor", + exportLogs: "Exportar Logs", + strategyHealthInspector: "Inspetor de Saúde da Estratégia", + healthScore: "Pontuação de Saúde", + tokenBucket: "Tokens", + consecutiveFailures: "Falhas", + refreshHealthData: "Atualizar", + healthNotAvailable: "Rastreamento de saúde disponível apenas com estratégia hybrid.", + // Placeholder Data + debugLogging: "Log de Debug", + debugLoggingDesc: "Mostrar mensagens de debug detalhadas no visualizador de logs.", + logExportToggle: "Exportar Logs", + logExportToggleDesc: "Mostrar botão de exportação na barra de ferramentas do visualizador de logs.", + healthInspectorToggle: "Inspetor de Saúde", + healthInspectorToggleDesc: "Mostrar inspetor de saúde da estratégia na visualização de contas.", + placeholderData: "Dados de Teste", + placeholderDataDesc: "Injetar contas e cotas fictícias para testes de interface.", + includeRealAccounts: "Incluir Contas Reais", + includeRealAccountsDesc: "Mostrar contas reais junto com dados de teste.", + // Editor de Configuração de Presets - novos campos + switchAccountDelay: "Atraso para Trocar Conta", + capacityBackoffTiers: "Níveis de Recuo de Capacidade", + scoringWeights: "Pesos de Pontuação", + healthScoreSection: "Pontuação de Saúde", + tokenBucketSection: "Balde de Tokens", + quotaAwarenessSection: "Consciência de Cota", + hsInitial: "Pontuação Inicial", + hsSuccessReward: "Recompensa por Sucesso", + hsRateLimitPenalty: "Penalidade por Limite", + hsFailurePenalty: "Penalidade por Falha", + hsRecoveryPerHour: "Recuperação/Hora", + hsMinUsable: "Pontuação Mín. Utilizável", + hsMaxScore: "Pontuação Máxima", + tbMaxTokens: "Máx. Tokens", + tbTokensPerMinute: "Tokens/Minuto", + tbInitialTokens: "Tokens Iniciais", + qLowThreshold: "Limite Baixo", + qCriticalThreshold: "Limite Crítico", + qStaleMs: "Timeout de Dados Obsoletos", + weightHealth: "Peso Saúde", + weightTokens: "Peso Tokens", + weightQuota: "Peso Cota", + weightLru: "Peso LRU", + // Mensagens de erro de presets + presetNameRequired: "Nome do preset é obrigatório", + deletePresetConfirm: "Excluir preset \"{name}\"?", + // Dropdown de Modelo + clearSelection: "Limpar seleção", +}; diff --git a/public/js/translations/tr.js b/public/js/translations/tr.js new file mode 100755 index 0000000000000000000000000000000000000000..9908297a52f5819b533b799f014dad4dadc711d0 --- /dev/null +++ b/public/js/translations/tr.js @@ -0,0 +1,459 @@ +/** + * Turkish (Türkçe) Translations + */ +window.translations = window.translations || {}; +window.translations.tr = { + dashboard: "Panel", + models: "Modeller", + accounts: "Hesaplar", + logs: "Günlükler", + settings: "Ayarlar", + online: "ÇEVRİMİÇİ", + offline: "ÇEVRİMDIŞI", + totalAccounts: "TOPLAM HESAP", + active: "AKTİF", + operational: "Çalışıyor", + rateLimited: "HIZ SINIRLANDI", + quotasDepleted: "{count}/{total} Kota Tükendi", + quotasDepletedTitle: "KOTA TÜKENDİ", + outOfTracked: "{total} Takip Edilen İçinden", + cooldown: "Soğuma", + searchPlaceholder: "Modelleri ara...", + allAccounts: "Tüm Hesaplar", + stat: "DURUM", + modelIdentity: "MODEL KİMLİĞİ", + globalQuota: "GENEL KOTA", + nextReset: "SONRAKİ SIFIRLAMA", + distribution: "HESAP DAĞILIMI", + systemConfig: "Sistem Yapılandırması", + language: "Dil", + pollingInterval: "Veri Güncelleme Aralığı", + maxDisplayLogs: "Maksimum Görüntülenen Günlük", + showExhausted: "Tükenmiş Modelleri Göster", + showExhaustedDesc: "%0 kotası kalan modelleri de göster.", + compactMode: "Kompakt Mod", + compactModeDesc: "Daha fazla bilgi göstermek için tablo boşluklarını azalt.", + showConfigWarning: "Yapılandırma Uyarısını Göster", + showConfigWarningDesc: "Claude CLI yapılandırması ön ayarla eşleşmediğinde Panoda uyarı göster.", + saveChanges: "Değişiklikleri Kaydet", + autoScroll: "Otomatik Kaydır", + clearLogs: "Günlükleri Temizle", + accountManagement: "Hesap Yönetimi", + manageTokens: "Google Hesap jetonlarını ve yetkilendirme durumlarını yönet", + addAccount: "Hesap Ekle", + status: "DURUM", + enabled: "ETKİN", + health: "SAĞLIK", + accountEmail: "HESAP (E-POSTA)", + source: "KAYNAK", + projectId: "PROJE ID", + sessionState: "OTURUM DURUMU", + operations: "İŞLEMLER", + delete: "Sil", + confirmDelete: "Bu hesabı kaldırmak istediğinizden emin misiniz?", + cannotDeleteDatabase: "Silinemez: Bu hesap Antigravity veritabanından (salt okunur)", + connectGoogle: "Google Hesabı Bağla", + reauthenticated: "yeniden doğrulandı", + added: "eklendi", + successfully: "başarıyla", + accountAddedSuccess: "Hesap başarıyla eklendi", + accountReauthSuccess: "Hesap başarıyla yeniden doğrulandı", + failedToGetAuthUrl: "Yetkilendirme URL'si alınamadı", + failedToStartOAuth: "OAuth akışı başlatılamadı", + oauthInProgress: "OAuth devam ediyor. Lütfen açılır pencerede kimlik doğrulamayı tamamlayın...", + family: "Aile", + model: "Model", + activeSuffix: "Aktif", + manualReload: "Yapılandırmayı Yeniden Yükle", + // Tabs + tabInterface: "Arayüz", + tabClaude: "Claude CLI", + tabModels: "Modeller", + tabServer: "Sunucu Ayarları", + // Dashboard + linkedAccounts: "Bağlı Hesaplar", + noSignal: "SİNYAL YOK", + establishingUplink: "BAĞLANTI KURULUYOR...", + goToAccounts: "Hesaplara Git", + // Settings - Models + modelsDesc: "Model görünürlüğünü, sabitlemeyi ve istek yönlendirmeyi yapılandırın.", + modelsPageDesc: "Tüm mevcut modeller için gerçek zamanlı kota ve durum.", + showHidden: "Gizli Modelleri Göster", + hideHidden: "Gizli Modelleri Gizle", + hiddenOn: "Gizli: AÇIK", + hiddenOff: "Gizli: KAPALI", + modelId: "Model ID", + actions: "İşlemler", + pinToTop: "En Üste Sabitle", + toggleVisibility: "Görünürlüğü Değiştir", + noModels: "MODEL ALGILANMADI", + modelMappingHint: "Sunucu tarafı model yönlendirme. Claude Code kullanıcıları: istemci tarafı kurulumu için 'Claude CLI' sekmesine bakın.", + modelMapping: "Eşleştirme (Hedef Model ID)", + // Settings - Claude + proxyConnection: "Proxy Bağlantısı", + modelSelection: "Model Seçimi", + defaultModelAliases: "VARSAYILAN MODEL TAKMA ADLARI", + opusAlias: "Opus Takma Adı", + sonnetAlias: "Sonnet Takma Adı", + haikuAlias: "Haiku Takma Adı", + claudeSettingsAlertPrefix: "Aşağıdaki ayarlar doğrudan değiştirir", + claudeSettingsAlertSuffix: "Uygulamak için Claude CLI'yı yeniden başlatın.", + applyToClaude: "Claude CLI'ya Uygula", + // Server Presets + serverPresets: "Sunucu Ön Ayarları", + serverPresetsHint: "Bir ön ayar seçin ve uygulamak için indirme simgesine tıklayın. Yerleşik ön ayarlar (\uD83D\uDD12) silinemez.", + loadServerPreset: "Sunucu ön ayarını yükle", + saveServerPreset: "Sunucu Ön Ayarı Kaydet", + saveServerPresetDesc: "Mevcut sunucu yapılandırmasını yeniden kullanılabilir ön ayar olarak kaydedin.", + serverPresetLoaded: "\"{name}\" ön ayarı uygulandı", + serverPresetSaved: "Sunucu ön ayarı kaydedildi", + serverPresetDeleted: "Sunucu ön ayarı silindi", + cannotDeleteBuiltIn: "Yerleşik ön ayarlar silinemez", + failedToLoadServerPreset: "Ön ayar uygulanamadı", + failedToSaveServerPreset: "Ön ayar kaydedilemedi", + editServerPreset: "Ön Ayarı Düzenle", + editServerPresetDesc: "Ön ayar adını veya açıklamasını güncelleyin.", + editPreset: "Ön ayarı düzenle", + serverPresetUpdated: "Ön ayar güncellendi", + failedToEditServerPreset: "Ön ayar güncellenemedi", + failedToDeleteServerPreset: "Ön ayar silinemedi", + // Server Preset Descriptions + presetDefaultDesc: "Küçük hesap havuzları için dengeli ayarlar. Orta düzey yeniden deneme ve bekleme süreleriyle hibrit strateji kullanır. 3-5 hesabı olan çoğu kullanıcı için idealdir.", + presetManyAccountsDesc: "Büyük hesap havuzları (10+) için optimize edilmiştir. Agresif yeniden denemeler, daha kısa bekleme süreleri ve daha yüksek kota koruması ile hızlı rotasyon. Birçok hesapta maksimum verim sağlar.", + presetConservativeDesc: "Hızdan çok güvenilirliğe öncelik verir. Önbellek sürekliliği için yapışkan strateji, daha uzun yeniden deneme/bekleme süreleri ve %20 kota koruması kullanır. Kararlılığın en önemli olduğu durumlar için idealdir.", + presetDescPlaceholder: "Açıklama (isteğe bağlı)", + presetPreviewLabel: "Yapılandırmayı Önizle", + presetPreviewHide: "Önizlemeyi Gizle", + editPresetConfig: "Yapılandırmayı Düzenle", + presetConfigSaved: "Ön ayar yapılandırması güncellendi", + invalidJson: "Geçersiz JSON", + failedToSavePresetConfig: "Ön ayar yapılandırması güncellenemedi", + presetConfigValidationError: "Lütfen kaydetmeden önce doğrulama hatalarını düzeltin", + // Settings - Server + port: "Port", + uiVersion: "Arayüz Sürümü", + debugMode: "Hata Ayıklama Modu", + environment: "Ortam", + serverReadOnly: "Ayarlar config.json üzerinden yönetilir. Değişiklikleri uygulamak için sunucuyu yeniden başlatın.", + advancedSettings: "Gelişmiş Ayarlar", + reloadConfigTitle: "Hesap Yapılandırmasını Yeniden Yükle", + reloadConfigDesc: "accounts.json dosyasını diskten yeniden yüklemeye zorla", + reload: "Yeniden Yükle", + // Config Specific + primaryModel: "Birincil Model", + subAgentModel: "Alt Ajan Modeli", + advancedOverrides: "Varsayılan Model Geçersiz Kılmaları", + opusModel: "Opus Modeli", + sonnetModel: "Sonnet Modeli", + haikuModel: "Haiku Modeli", + authToken: "Yetkilendirme Jetonu", + saveConfig: "Claude CLI ayarlarına kaydet", + envVar: "Ortam Değişkeni", + // New Keys + systemName: "ANTIGRAVITY", + systemDesc: "CLAUDE PROXY SİSTEMİ", + connectGoogleDesc: "API kota limitinizi artırmak için bir Google Workspace hesabı bağlayın. Bu hesap, Claude isteklerini Antigravity üzerinden vekil sunucu olarak kullanmak için kullanılacaktır.", + useCliCommand: "CLI Komutunu Kullan", + close: "Kapat", + requestVolume: "İstek Hacmi", + filter: "Filtrele", + all: "Tümü", + none: "Hiçbiri", + noDataTracked: "Henüz veri izlenmedi", + selectFamilies: "Görüntülenecek aileleri seçin", + selectModels: "Görüntülenecek modelleri seçin", + noLogsMatch: "Filtreyle eşleşen günlük yok", + connecting: "BAĞLANILIYOR", + main: "Ana", + system: "Sistem", + refreshData: "Verileri Yenile", + connectionLost: "Bağlantı Kaybedildi", + lastUpdated: "Son Güncelleme", + grepLogs: "günlükleri ara...", + noMatchingModels: "Eşleşen model yok", + typeToSearch: "Aramak veya seçmek için yazın...", + or: "VEYA", + refreshingAccount: "{email} yenileniyor...", + refreshedAccount: "{email} yenilendi", + refreshFailed: "Yenileme başarısız", + accountToggled: "Hesap {email} {status}", + toggleFailed: "Değiştirme başarısız", + reauthenticating: "{email} yeniden doğrulanıyor...", + authUrlFailed: "Yetkilendirme URL'si alınamadı", + deletedAccount: "{email} silindi", + deleteFailed: "Silme başarısız", + accountsReloaded: "Hesaplar yeniden yüklendi", + reloadFailed: "Yeniden yükleme başarısız", + claudeConfigSaved: "Claude yapılandırması kaydedildi", + claudeConfigRestored: "Claude CLI varsayılanlara geri yüklendi", + saveConfigFailed: "Yapılandırma kaydedilemedi", + restoreConfigFailed: "Yapılandırma geri yüklenemedi", + restoreDefault: "Varsayılana Dön", + confirmRestoreTitle: "Geri Yüklemeyi Onayla", + confirmRestoreMessage: "Claude CLI'yı varsayılan ayarlara geri yüklemek istediğinizden emin misiniz? Bu işlem proxy yapılandırmasını kaldıracaktır.", + confirmRestore: "Geri Yüklemeyi Onayla", + claudeConfigNotApplied: "Claude CLI yapılandırılmamış", + claudeConfigNotAppliedDesc: "Proxy'yi Claude Code ile kullanmak için bir ön ayar uygulayın.", + configureNow: "Yapılandır", + claudeActive: "Claude Aktif", + claudeEmpty: "Claude Boş", + geminiActive: "Gemini Aktif", + geminiEmpty: "Gemini Boş", + synced: "EŞİTLENDİ", + syncing: "EŞİTLENİYOR...", + // Time range labels + last1Hour: "Son 1S", + last6Hours: "Son 6S", + last24Hours: "Son 24S", + last7Days: "Son 7G", + allTime: "Tüm Zamanlar", + groupBy: "Gruplama Ölçütü", + // Additional + reloading: "Yeniden yükleniyor...", + reloaded: "Yeniden yüklendi", + lines: "satır", + enabledSeeLogs: "Etkin (Günlüklere Bak)", + production: "Üretim", + configSaved: "Yapılandırma Kaydedildi", + enterPassword: "Web UI Parolasını Girin:", + ready: "HAZIR", + depleted: "Tükendi", + timeH: "S", + timeM: "D", + familyClaude: "Claude", + familyGemini: "Gemini", + familyOther: "Diğer", + enabledStatus: "etkin", + disabledStatus: "devre dışı", + logLevelInfo: "BİLGİ", + logLevelSuccess: "BAŞARILI", + logLevelWarn: "UYARI", + logLevelError: "HATA", + totalColon: "Toplam:", + todayColon: "Bugün:", + hour1Colon: "1S:", + frequentModels: "Sık Kullanılan", + smartTitle: "En çok kullanılan 5 modeli otomatik seç (24s)", + activeCount: "{count} Aktif", + allCaps: "TÜMÜ", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + modelMapping: "Eşleştirme (Hedef Model ID)", + systemInfo: "Sistem Bilgisi", + refresh: "Yenile", + runtimeConfig: "Çalışma Zamanı Yapılandırması", + debugDesc: "Ayrıntılı günlük kaydını etkinleştir (Günlükler sekmesine bakın)", + networkRetry: "Ağ Yeniden Deneme Ayarları", + maxRetries: "Maksimum Deneme", + retryBaseDelay: "Yeniden Deneme Temel Gecikmesi (ms)", + retryMaxDelay: "Yeniden Deneme Maksimum Gecikmesi (ms)", + persistentSessions: "Kalıcı Oturumlar", + persistTokenDesc: "Daha hızlı yeniden başlatmalar için OAuth oturumlarını diske kaydet", + rateLimiting: "Hesap Hız Sınırlama ve Zaman Aşımları", + defaultCooldown: "Varsayılan Soğuma Süresi", + defaultCooldownDesc: "API sıfırlama zamanı sağlamadığında yedek soğuma süresi.", + maxWaitThreshold: "Maksimum Bekleme Eşiği (Yapışkan)", + maxWaitDesc: "Yapışkan bir hesabın değiştirmeden önce sıfırlanması için beklenecek maksimum süre.", + // Kota Koruması + quotaProtection: "Kota Koruması", + minimumQuotaLevel: "Minimum Kota Seviyesi", + minimumQuotaLevelDesc: "Kota bu seviyenin altına düştüğünde hesap değiştir. Hesap bazlı ayarlar önceliklidir.", + quotaDisabled: "Devre Dışı", + // Hata İşleme Ayarları + errorHandlingTuning: "Hata İşleme Ayarları", + rateLimitDedupWindow: "Hız Sınırı Tekilleştirme Penceresi", + rateLimitDedupWindowDesc: "Birden fazla istek aynı anda hız sınırına ulaştığında yeniden deneme fırtınasını önler.", + maxConsecutiveFailures: "Maks. Ardışık Başarısızlık", + maxConsecutiveFailuresDesc: "Uzatılmış soğuma uygulamadan önce ardışık başarısızlık sayısı.", + extendedCooldown: "Uzatılmış Soğuma", + extendedCooldownDesc: "Maks. ardışık başarısızlık sonrası uygulanan soğuma süresi.", + maxCapacityRetries: "Maks. Kapasite Yeniden Denemesi", + maxCapacityRetriesDesc: "Hesap değiştirmeden önce kapasite tükenmesi için maksimum yeniden deneme.", + saveConfigServer: "Yapılandırmayı Kaydet", + serverRestartAlert: "Değişiklikler {path} konumuna kaydedildi. Bazı ayarları uygulamak için sunucuyu yeniden başlatın.", + changePassword: "WebUI Parolasını Değiştir", + changePasswordDesc: "Bu panoya erişim parolasını güncelle", + currentPassword: "Mevcut Parola", + newPassword: "Yeni Parola", + confirmNewPassword: "Yeni Parolayı Onayla", + passwordEmptyDesc: "Parola ayarlanmamışsa boş bırakın", + passwordLengthDesc: "En az 6 karakter", + passwordConfirmDesc: "Yeni parolayı tekrar girin", + cancel: "İptal", + passwordsNotMatch: "Parolalar eşleşmiyor", + passwordTooShort: "Parola en az 6 karakter olmalıdır", + // Dashboard drill-down + clickToViewAllAccounts: "Tüm hesapları görüntülemek için tıklayın", + clickToViewModels: "Modeller sayfasını görüntülemek için tıklayın", + clickToViewLimitedAccounts: "Hız sınırı olan hesapları görüntülemek için tıklayın", + clickToFilterClaude: "Claude modellerini filtrelemek için tıklayın", + clickToFilterGemini: "Gemini modellerini filtrelemek için tıklayın", + // Accounts page + searchAccounts: "Hesap ara...", + noAccountsYet: "Henüz Hesap Yok", + noAccountsDesc: "OAuth üzerinden bir Google hesabı ekleyerek veya kimlik bilgilerini içe aktarmak için CLI komutunu kullanarak başlayın.", + addFirstAccount: "İlk Hesabınızı Ekleyin", + noSearchResults: "Aramanızla eşleşen hesap yok", + clearSearch: "Aramayı Temizle", + disabledAccountsNote: "Devre dışı bırakılan hesaplar istek yönlendirmesi için kullanılmayacak ancak yapılandırmada kalacaktır. Pano istatistikleri yalnızca etkin hesapları içerir.", + dangerousOperation: "⚠️ Tehlikeli İşlem", + confirmDeletePrompt: "Hesabı silmek istediğinizden emin misiniz", + deleteWarning: "⚠️ Bu işlem geri alınamaz. Tüm yapılandırma ve geçmiş kayıtları kalıcı olarak silinecektir.", + // OAuth progress + oauthWaiting: "OAuth yetkilendirmesi bekleniyor...", + oauthWaitingDesc: "Lütfen açılır pencerede kimlik doğrulamasını tamamlayın. Bu işlem 2 dakikaya kadar sürebilir.", + oauthCancelled: "OAuth yetkilendirmesi iptal edildi", + oauthTimeout: "⏱️ OAuth yetkilendirmesi zaman aşımına uğradı. Lütfen tekrar deneyin.", + oauthWindowClosed: "OAuth penceresi kapatıldı. Yetkilendirme tamamlanmamış olabilir.", + cancelOAuth: "İptal", + // MCP CLI & Gemini 1M + mcpCliExperimental: "Deneysel MCP CLI", + mcpCliDesc: "Daha az bağlam tüketimi ile güvenilir araç kullanımı için deneysel MCP entegrasyonunu etkinleştirir.", + gemini1mMode: "Gemini 1M Bağlam Modu", + gemini1mDesc: "1M bağlam penceresi desteği için Gemini modellerine [1m] son eki ekler.", + gemini1mWarning: "⚠ Büyük bağlam, Gemini performansını düşürebilir.", + clickToSet: "Yapılandırmak için tıklayın...", + none: "Hiçbiri", + // Quota Distribution + quotaDistribution: "Kota Dağılımı", + resetsIn: "{time} içinde sıfırlanır", + noQuotaData: "Bu hesap için henüz kota verisi yok.", + // Manual OAuth Mode + manualMode: "Manuel Mod", + manualModeDesc: "(callback ulaşamadığında kullan)", + authLinkLabel: "Yetkilendirme Linki:", + linkCopied: "Link panoya kopyalandı", + pasteCallbackLabel: "Callback URL veya kodu yapıştır:", + pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... veya 4/0xxx...", + completeAuth: "Yetkilendirmeyi Tamamla", + authFailed: "Yetkilendirme başarısız", + // Import/Export + export: "Dışa Aktar", + import: "İçe Aktar", + exportAccounts: "Hesapları Dışa Aktar", + importAccounts: "Hesapları İçe Aktar", + exportSuccess: "{count} hesap dışa aktarıldı", + exportFailed: "Dışa aktarma başarısız", + importSuccess: "İçe aktarma tamamlandı:", + importFailed: "İçe aktarma başarısız", + + // TODO: Missing translations - Hardcoded strings from HTML + // pageTitle: "Antigravity Console", + // live: "Live", + // tier: "Tier", + // quota: "Quota", + // tierUltra: "Ultra", + // tierPro: "Pro", + // tierFree: "Free", + // menu: "Menu", + // github: "GitHub", + // noData: "No data", + // fix: "Fix", + + // TODO: Missing translations - Hardcoded strings from JS (Error Messages) + // operationFailed: "Operation failed", + // unknownError: "Unknown error", + presetNameRequired: "Ön ayar adı gerekli", + // saveFailed: "Save failed", + // failedToSavePreset: "Failed to save preset", + // noPresetSelected: "No preset selected", + deletePresetConfirm: "Ön ayar \"{name}\" silinsin mi?", + // deleteFailed: "Delete failed", + // failedToDeletePreset: "Failed to delete preset", + // failedToChangePassword: "Failed to change password", + // passwordChangedSuccess: "Password changed successfully", + // debugModeToggled: "Debug mode {status}", + // tokenCacheToggled: "Token cache {status}", + // failedToUpdateTokenCache: "Failed to update token cache", + // failedToUpdateDebugMode: "Failed to update debug mode", + // failedToRefreshAccount: "Failed to refresh account", + // failedToDeleteAccount: "Failed to delete account", + // failedToReloadAccounts: "Failed to reload accounts", + // failedToUpdateModelConfig: "Failed to update model config", + // fieldUpdated: "{displayName} updated to {value}", + // failedToUpdateField: "Failed to update {displayName}", + + // TODO: Missing translations - Validation messages from validators.js + // mustBeValidNumber: "{fieldName} must be a valid number", + // mustBeAtLeast: "{fieldName} must be at least {min}", + // mustBeAtMost: "{fieldName} must be at most {max}", + // cannotBeEmpty: "{fieldName} cannot be empty", + // mustBeTrueOrFalse: "Value must be true or false", + + // TODO: Missing translation - Server config (exists in EN but missing here) + // defaultCooldownDesc: "Fallback cooldown when API doesn't provide a reset time.", + + // Account Selection Strategy translations + accountSelectionStrategy: "Hesap Seçim Stratejisi", + selectionStrategy: "Seçim Stratejisi", + strategyStickyLabel: "Sabit (Önbellek Optimizasyonu)", + strategyRoundRobinLabel: "Döngüsel (Yük Dengeleme)", + strategyHybridLabel: "Hibrit (Akıllı Dağıtım)", + strategyStickyDesc: "Hız sınırına ulaşılana kadar aynı hesapta kalır. Önbellek için en iyisi.", + strategyRoundRobinDesc: "Her istekte bir sonraki hesaba geçer. Maksimum verimlilik.", + strategyHybridDesc: "Sağlık, token ve tazeliğe dayalı akıllı seçim.", + strategyUpdated: "Strateji şu şekilde güncellendi: {strategy}", + failedToUpdateStrategy: "Strateji güncellenemedi", + invalidStrategy: "Geçersiz strateji seçildi", + // Mode Toggle (Proxy/Paid) + connectionMode: "Bağlantı Modu", + proxyMode: "Proxy Modu", + paidMode: "Ücretli Mod", + usingLocalProxy: "Yerel proxy sunucusu kullanılıyor (localhost:{port})", + usingOfficialApi: "Resmi Anthropic API kullanılıyor (abonelik gerektirir)", + paidModeTitle: "Claude CLI resmi Anthropic API kullanıyor", + paidModeDesc: "Tüm proxy yapılandırması kaldırıldı. Claude CLI doğrudan Anthropic aboneliğinizi kullanır.", + paidModeHint: "Model yönlendirme ve ön ayarları yapılandırmak için Proxy moduna geçin.", + modeToggleFailed: "Mod değiştirilemedi", + screenshotMode: "Ekran Görüntüsü Modu", + screenshotModeDesc: "Güvenli ekran paylaşımı için hesap bilgilerini gizle.", + // Developer Mode + devMode: "Geliştirici Modu", + devModeDesc: "Hata ayıklama günlükleri, günlük dışa aktarma ve strateji sağlık denetçisini etkinleştirir.", + devModeToggled: "Geliştirici modu {status}", + failedToUpdateDevMode: "Geliştirici modu güncellenemedi", + exportLogs: "Günlükleri Dışa Aktar", + strategyHealthInspector: "Strateji Sağlık Denetçisi", + healthScore: "Sağlık Puanı", + tokenBucket: "Tokenlar", + consecutiveFailures: "Hatalar", + refreshHealthData: "Yenile", + healthNotAvailable: "Sağlık takibi yalnızca hybrid stratejide kullanılabilir.", + // Placeholder Data + debugLogging: "Hata Ayıklama Günlükleri", + debugLoggingDesc: "Günlük görüntüleyicide ayrıntılı hata ayıklama mesajlarını göster.", + logExportToggle: "Günlük Dışa Aktarma", + logExportToggleDesc: "Günlük görüntüleyici araç çubuğunda dışa aktarma düğmesini göster.", + healthInspectorToggle: "Sağlık Denetçisi", + healthInspectorToggleDesc: "Hesap görünümünde strateji sağlık denetçisini göster.", + placeholderData: "Yer Tutucu Veriler", + placeholderDataDesc: "UI testi için sahte hesaplar ve kotalar ekle.", + includeRealAccounts: "Gerçek Hesapları Dahil Et", + includeRealAccountsDesc: "Yer tutucu verilerle birlikte gerçek hesapları göster.", + // Sunucu Ön Ayar Yapılandırma Düzenleyicisi - yeni alanlar + switchAccountDelay: "Hesap Değiştirme Gecikmesi", + capacityBackoffTiers: "Kapasite Geri Çekilme Katmanları", + scoringWeights: "Puanlama Ağırlıkları", + healthScoreSection: "Sağlık Puanı", + tokenBucketSection: "Token Kovası", + quotaAwarenessSection: "Kota Farkındalığı", + hsInitial: "Başlangıç Puanı", + hsSuccessReward: "Başarı Ödülü", + hsRateLimitPenalty: "Hız Limiti Cezası", + hsFailurePenalty: "Başarısızlık Cezası", + hsRecoveryPerHour: "Saatlik Kurtarma", + hsMinUsable: "Min Kullanılabilir Puan", + hsMaxScore: "Maks Puan", + tbMaxTokens: "Maks Token", + tbTokensPerMinute: "Token/Dakika", + tbInitialTokens: "Başlangıç Tokenları", + qLowThreshold: "Düşük Eşik", + qCriticalThreshold: "Kritik Eşik", + qStaleMs: "Eski Veri Zaman Aşımı", + weightHealth: "Sağlık Ağırlığı", + weightTokens: "Token Ağırlığı", + weightQuota: "Kota Ağırlığı", + weightLru: "LRU Ağırlığı", + // Model Açılır Menüsü + clearSelection: "Seçimi temizle", +}; diff --git a/public/js/translations/zh.js b/public/js/translations/zh.js new file mode 100755 index 0000000000000000000000000000000000000000..ae026898813aa07043bfcceac8a0456b55707db9 --- /dev/null +++ b/public/js/translations/zh.js @@ -0,0 +1,474 @@ +/** + * Chinese (中文) Translations + */ +window.translations = window.translations || {}; +window.translations.zh = { + dashboard: "仪表盘", + models: "模型列表", + accounts: "账号管理", + logs: "运行日志", + settings: "系统设置", + online: "在线", + offline: "离线", + totalAccounts: "账号总数", + active: "活跃状态", + operational: "运行中", + rateLimited: "受限状态", + quotasDepleted: "{count}/{total} 配额耗尽", + quotasDepletedTitle: "配额耗尽数", + outOfTracked: "共追踪 {total} 个", + cooldown: "冷却中", + searchPlaceholder: "搜索模型...", + allAccounts: "所有账号", + stat: "状态", + modelIdentity: "模型标识", + globalQuota: "全局配额", + nextReset: "重置时间", + distribution: "账号分布", + systemConfig: "系统配置", + language: "语言设置", + pollingInterval: "数据轮询间隔", + maxDisplayLogs: "最大日志显示行数", + showExhausted: "显示耗尽模型", + showExhaustedDesc: "即使配额为 0% 也显示模型。", + compactMode: "紧凑模式", + compactModeDesc: "减少表格间距以显示更多信息。", + showConfigWarning: "显示配置警告", + showConfigWarningDesc: "当 Claude CLI 配置与预设不一致时,在仪表板显示警告。", + saveChanges: "保存更改", + autoScroll: "自动滚动", + clearLogs: "清除日志", + accountManagement: "账号管理", + manageTokens: "管理已授权的 Google 账号及其状态", + addAccount: "添加账号", + status: "状态", + enabled: "启用", + health: "状态", + accountEmail: "账号 (邮箱)", + source: "来源", + projectId: "项目 ID", + sessionState: "会话状态", + operations: "操作", + delete: "删除", + confirmDelete: "确定要移除此账号吗?", + cannotDeleteDatabase: "无法删除:此账号来自 Antigravity 数据库(只读)", + connectGoogle: "连接 Google 账号", + reauthenticated: "已重新认证", + added: "已添加", + successfully: "成功", + accountAddedSuccess: "账号添加成功", + accountReauthSuccess: "账号重新认证成功", + failedToGetAuthUrl: "获取认证链接失败", + failedToStartOAuth: "启动 OAuth 流程失败", + oauthInProgress: "OAuth 授权进行中,请在弹出窗口中完成认证...", + family: "系列", + model: "模型", + activeSuffix: "活跃", + manualReload: "重新加载配置", + // Tabs + tabInterface: "界面设置", + tabClaude: "Claude CLI", + tabModels: "模型管理", + tabServer: "服务器设置", + // Dashboard + linkedAccounts: "已关联账号", + noSignal: "无信号连接", + establishingUplink: "正在建立上行链路...", + goToAccounts: "前往账号管理", + // Settings - Models + modelsDesc: "配置模型的可见性、置顶和请求路由。", + modelsPageDesc: "所有可用模型的实时配额和状态。", + showHidden: "显示隐藏模型", + hideHidden: "隐藏被屏蔽模型", + hiddenOn: "隐藏模型: 显示", + hiddenOff: "隐藏模型: 隐藏", + modelId: "模型 ID", + actions: "操作", + pinToTop: "置顶", + toggleVisibility: "切换可见性", + noModels: "未检测到模型", + modelMappingHint: "服务端模型路由功能。Claude Code 用户请使用 'Claude CLI' 标签页以便捷配置。", + modelMapping: "映射 (目标模型 ID)", + // Settings - Claude + proxyConnection: "代理连接", + modelSelection: "模型选择", + defaultModelAliases: "默认模型映射 (别名)", + opusAlias: "Opus 别名", + sonnetAlias: "Sonnet 别名", + haikuAlias: "Haiku 别名", + claudeSettingsAlertPrefix: "以下设置直接修改", + claudeSettingsAlertSuffix: "重启 Claude CLI 生效。", + applyToClaude: "应用到 Claude CLI", + // Presets + configPresets: "配置预设", + saveAsPreset: "另存为预设", + deletePreset: "删除预设", + loadPreset: "加载预设到表单", + load: "加载", + presetHint: "选择预设以加载。点击“应用到 Claude CLI”以保存更改。", + presetLoaded: "预设已加载。点击 \"应用到 Claude CLI\" 以保存。", + presetSaved: "预设已保存", + presetDeleted: "预设已删除", + claudeConfigNotApplied: "Claude CLI 未配置", + claudeConfigNotAppliedDesc: "应用预设以使用代理访问 Claude Code。", + configureNow: "前往配置", + unsavedChangesTitle: "未保存的更改", + unsavedChangesMessage: "当前配置与任何已保存的预设都不匹配。如果切换预设,当前未保存的设置将会丢失。", + loadAnyway: "仍然加载", + savePresetTitle: "保存预设", + savePresetDesc: "将当前配置保存为可重复使用的预设。", + presetName: "预设名称", + presetNamePlaceholder: "例如:工作配置", + savePreset: "保存预设", + // Server Presets + serverPresets: "服务器预设", + serverPresetsHint: "选择预设后点击下载图标应用。内置预设(\uD83D\uDD12)不可删除。", + loadServerPreset: "加载服务器预设", + saveServerPreset: "保存服务器预设", + saveServerPresetDesc: "将当前服务器配置保存为可重复使用的预设。", + serverPresetLoaded: "预设 \"{name}\" 已应用", + serverPresetSaved: "服务器预设已保存", + serverPresetDeleted: "服务器预设已删除", + cannotDeleteBuiltIn: "无法删除内置预设", + failedToLoadServerPreset: "应用预设失败", + failedToSaveServerPreset: "保存预设失败", + editServerPreset: "编辑预设", + editServerPresetDesc: "更新预设名称或描述。", + editPreset: "编辑预设", + serverPresetUpdated: "预设已更新", + failedToEditServerPreset: "更新预设失败", + failedToDeleteServerPreset: "删除预设失败", + // Server Preset Descriptions + presetDefaultDesc: "适合小型账号池的均衡配置。使用混合策略,适中的重试和冷却时间。适合拥有 3-5 个账号的大多数用户。", + presetManyAccountsDesc: "针对大型账号池(10+)优化。更快的轮换、更积极的重试、更短的冷却时间和更高的配额保护。最大化多账号吞吐量。", + presetConservativeDesc: "优先考虑稳定性而非速度。使用粘性策略保持缓存连续性,更长的重试/冷却时间和 20% 配额保护。适合稳定性至关重要的场景。", + presetDescPlaceholder: "描述(可选)", + presetPreviewLabel: "预览配置", + presetPreviewHide: "隐藏预览", + editPresetConfig: "编辑配置", + presetConfigSaved: "预设配置已更新", + invalidJson: "无效的 JSON", + failedToSavePresetConfig: "更新预设配置失败", + presetConfigValidationError: "请先修复验证错误再保存", + // Settings - Server + port: "端口", + uiVersion: "UI 版本", + debugMode: "调试模式", + environment: "运行环境", + serverReadOnly: "配置由 config.json 管理。重启服务器以应用更改。", + advancedSettings: "高级设置", + reloadConfigTitle: "重载账号配置", + reloadConfigDesc: "强制从磁盘重新读取 accounts.json", + reload: "重载", + // Config Specific + primaryModel: "主模型", + subAgentModel: "子代理模型", + advancedOverrides: "默认模型覆盖 (高级)", + opusModel: "Opus 模型", + sonnetModel: "Sonnet 模型", + haikuModel: "Haiku 模型", + authToken: "认证令牌", + saveConfig: "保存到 Claude CLI 设置", + envVar: "环境变量", + // New Keys + systemName: "ANTIGRAVITY", + systemDesc: "CLAUDE 代理系统", + connectGoogleDesc: "连接 Google Workspace 账号以增加 API 配额。该账号将用于通过 Antigravity 代理 Claude 请求。", + useCliCommand: "使用命令行", + close: "关闭", + requestVolume: "请求量", + filter: "筛选", + all: "全选", + none: "清空", + noDataTracked: "暂无追踪数据", + selectFamilies: "选择要显示的系列", + selectModels: "选择要显示的模型", + noLogsMatch: "没有符合过滤条件的日志", + connecting: "正在连接", + main: "主菜单", + system: "系统", + refreshData: "刷新数据", + connectionLost: "连接已断开", + lastUpdated: "最后更新", + grepLogs: "过滤日志...", + noMatchingModels: "没有匹配的模型", + typeToSearch: "输入以搜索或选择...", + or: "或", + refreshingAccount: "正在刷新 {email}...", + refreshedAccount: "已完成刷新 {email}", + refreshFailed: "刷新失败", + accountToggled: "账号 {email} 已{status}", + toggleFailed: "切换失败", + reauthenticating: "正在重新认证 {email}...", + authUrlFailed: "获取认证链接失败", + deletedAccount: "已删除 {email}", + deleteFailed: "删除失败", + accountsReloaded: "账号配置已重载", + reloadFailed: "重载失败", + claudeConfigSaved: "Claude 配置已保存", + claudeConfigRestored: "Claude CLI 已恢复默认设置", + saveConfigFailed: "保存配置失败", + restoreConfigFailed: "恢复配置失败", + restoreDefault: "恢复默认", + confirmRestoreTitle: "确认恢复", + confirmRestoreMessage: "确定要将 Claude CLI 恢复为默认设置吗?这将移除代理配置。", + confirmRestore: "确认恢复", + claudeActive: "Claude 活跃", + claudeEmpty: "Claude 耗尽", + geminiActive: "Gemini 活跃", + geminiEmpty: "Gemini 耗尽", + synced: "已同步", + syncing: "正在同步...", + // 时间范围标签 + last1Hour: "最近 1 小时", + last6Hours: "最近 6 小时", + last24Hours: "最近 24 小时", + last7Days: "最近 7 天", + allTime: "最后全部", + groupBy: "分组方式", + // Additional + reloading: "正在重载...", + reloaded: "已重载", + lines: "行", + enabledSeeLogs: "已启用 (见日志)", + production: "生产环境", + configSaved: "配置已保存", + enterPassword: "请输入 Web UI 密码:", + ready: "就绪", + depleted: "已耗尽", + timeH: "时", + timeM: "分", + familyClaude: "Claude 系列", + familyGemini: "Gemini 系列", + familyOther: "其他系列", + enabledStatus: "已启用", + disabledStatus: "已禁用", + logLevelInfo: "信息", + logLevelSuccess: "成功", + logLevelWarn: "警告", + logLevelError: "错误", + totalColon: "总计:", + todayColon: "今日:", + hour1Colon: "1小时:", + frequentModels: "常用推荐", + smartTitle: "自动选出过去 24 小时最常用的 5 个模型", + activeCount: "{count} 活跃", + allCaps: "全部", + claudeCaps: "CLAUDE", + geminiCaps: "GEMINI", + modelMapping: "映射 (目标模型 ID)", + systemInfo: "系统信息", + refresh: "刷新", + runtimeConfig: "运行时配置", + debugDesc: "启用详细日志记录 (见运行日志)", + networkRetry: "网络重试设置", + maxRetries: "最大重试次数", + retryBaseDelay: "重试基础延迟 (毫秒)", + retryMaxDelay: "重试最大延迟 (毫秒)", + persistentSessions: "持久化登录会话", + persistTokenDesc: "将登录会话保存到磁盘以实现快速重启", + rateLimiting: "账号限流与超时", + defaultCooldown: "默认冷却时间", + defaultCooldownDesc: "当 API 未提供重置时间时的备用冷却时间。", + maxWaitThreshold: "最大等待阈值", + maxWaitDesc: "如果所有账号的限流时间超过此阈值,立即返回错误而非等待。", + // 配额保护 + quotaProtection: "配额保护", + minimumQuotaLevel: "最低配额水平", + minimumQuotaLevelDesc: "当配额低于此水平时切换账号。每个账号的单独设置优先。", + quotaDisabled: "已禁用", + // 错误处理调优 + errorHandlingTuning: "错误处理调优", + rateLimitDedupWindow: "限流去重窗口", + rateLimitDedupWindowDesc: "当多个请求同时触发限流时,防止并发重试风暴。", + maxConsecutiveFailures: "最大连续失败次数", + maxConsecutiveFailuresDesc: "触发扩展冷却前允许的连续失败次数。", + extendedCooldown: "扩展冷却时间", + extendedCooldownDesc: "达到最大连续失败后应用的冷却时长。", + maxCapacityRetries: "最大容量重试次数", + maxCapacityRetriesDesc: "容量耗尽时在切换账号前的最大重试次数。", + saveConfigServer: "保存配置", + serverRestartAlert: "配置已保存至 {path}。部分更改可能需要重启服务器。", + changePassword: "修改 WebUI 密码", + changePasswordDesc: "更新访问此仪表盘的密码", + currentPassword: "当前密码", + newPassword: "新密码", + confirmNewPassword: "确认新密码", + passwordEmptyDesc: "如果未设置密码请留空", + passwordLengthDesc: "至少 6 个字符", + passwordConfirmDesc: "请再次输入新密码", + cancel: "取消", + passwordsNotMatch: "密码不匹配", + passwordTooShort: "密码至少需要 6 个字符", + // Dashboard drill-down + clickToViewAllAccounts: "点击查看所有账号", + clickToViewModels: "点击查看模型页面", + clickToViewLimitedAccounts: "点击查看受限账号", + clickToFilterClaude: "点击筛选 Claude 模型", + clickToFilterGemini: "点击筛选 Gemini 模型", + // 账号页面 + searchAccounts: "搜索账号...", + noAccountsYet: "还没有添加任何账号", + noAccountsDesc: "点击上方的 \"添加账号\" 按钮通过 OAuth 添加 Google 账号,或者使用 CLI 命令导入凭证。", + addFirstAccount: "添加第一个账号", + noSearchResults: "没有找到匹配的账号", + clearSearch: "清除搜索", + disabledAccountsNote: "已禁用的账号不会用于请求路由,但仍保留在配置中。仪表盘统计数据仅包含已启用的账号。", + dangerousOperation: "⚠️ 危险操作", + confirmDeletePrompt: "确定要删除账号", + deleteWarning: "⚠️ 此操作不可撤销,账号的所有配置和历史记录将永久删除。", + // OAuth 进度 + oauthWaiting: "等待 OAuth 授权中...", + oauthWaitingDesc: "请在弹出窗口中完成认证。此过程最长可能需要 2 分钟。", + oauthCancelled: "已取消 OAuth 授权", + oauthTimeout: "⏱️ OAuth 授权超时,请重试。", + oauthWindowClosed: "OAuth 窗口已关闭,授权可能未完成。", + cancelOAuth: "取消", + // MCP CLI & Gemini 1M + mcpCliExperimental: "实验性 MCP CLI", + mcpCliDesc: "启用实验性 MCP 集成,减少上下文消耗,提高工具调用可靠性。", + gemini1mMode: "Gemini 1M 上下文模式", + gemini1mDesc: "为 Gemini 模型添加 [1m] 后缀以支持 1M 上下文窗口。", + gemini1mWarning: "⚠ 大上下文可能降低 Gemini 性能。", + clickToSet: "点击进行配置...", + none: "无", + // Quota Distribution + quotaDistribution: "配额分布", + resetsIn: "{time} 后重置", + noQuotaData: "暂无此账号的配额数据。", + // Manual OAuth Mode + manualMode: "手动模式", + manualModeDesc: "(当回调无法到达时使用)", + authLinkLabel: "授权链接:", + linkCopied: "链接已复制到剪贴板", + pasteCallbackLabel: "粘贴回调 URL 或授权码:", + pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... 或 4/0xxx...", + completeAuth: "完成授权", + authFailed: "授权失败", + // Import/Export + export: "导出", + import: "导入", + exportAccounts: "导出账号", + importAccounts: "导入账号", + exportSuccess: "已导出 {count} 个账号", + exportFailed: "导出失败", + importSuccess: "导入完成:", + importFailed: "导入失败", + + // TODO: Missing translations - Hardcoded strings from HTML + // pageTitle: "Antigravity Console", + // live: "Live", + // tier: "Tier", + // quota: "Quota", + // tierUltra: "Ultra", + // tierPro: "Pro", + // tierFree: "Free", + // menu: "Menu", + // github: "GitHub", + // noData: "No data", + // fix: "Fix", + + // TODO: Missing translations - Hardcoded strings from JS (Error Messages) + // operationFailed: "Operation failed", + // unknownError: "Unknown error", + presetNameRequired: "需要预设名称", + // saveFailed: "Save failed", + // failedToSavePreset: "Failed to save preset", + // noPresetSelected: "No preset selected", + deletePresetConfirm: "删除预设 \"{name}\"?", + // deleteFailed: "Delete failed", + // failedToDeletePreset: "Failed to delete preset", + // failedToChangePassword: "Failed to change password", + // passwordChangedSuccess: "Password changed successfully", + // debugModeToggled: "Debug mode {status}", + // tokenCacheToggled: "Token cache {status}", + // failedToUpdateTokenCache: "Failed to update token cache", + // failedToUpdateDebugMode: "Failed to update debug mode", + // failedToRefreshAccount: "Failed to refresh account", + // failedToDeleteAccount: "Failed to delete account", + // failedToReloadAccounts: "Failed to reload accounts", + // failedToUpdateModelConfig: "Failed to update model config", + // fieldUpdated: "{displayName} updated to {value}", + // failedToUpdateField: "Failed to update {displayName}", + + // TODO: Missing translations - Validation messages from validators.js + // mustBeValidNumber: "{fieldName} must be a valid number", + // mustBeAtLeast: "{fieldName} must be at least {min}", + // mustBeAtMost: "{fieldName} must be at most {max}", + // cannotBeEmpty: "{fieldName} cannot be empty", + // mustBeTrueOrFalse: "Value must be true or false", + + // Account Selection Strategy translations + accountSelectionStrategy: "账户选择策略", + selectionStrategy: "选择策略", + strategyStickyLabel: "固定 (缓存优化)", + strategyRoundRobinLabel: "轮询 (负载均衡)", + strategyHybridLabel: "混合 (智能分配)", + strategyStickyDesc: "保持使用同一账户直到被限速。最适合提示词缓存。", + strategyRoundRobinDesc: "每次请求轮换到下一个账户。最大吞吐量。", + strategyHybridDesc: "基于健康度、令牌和新鲜度的智能选择。", + strategyUpdated: "策略已更新为: {strategy}", + failedToUpdateStrategy: "更新策略失败", + invalidStrategy: "选择了无效的策略", + // Mode Toggle (Proxy/Paid) + connectionMode: "连接模式", + proxyMode: "代理模式", + paidMode: "付费模式", + usingLocalProxy: "使用本地代理服务器 (localhost:{port})", + usingOfficialApi: "使用官方 Anthropic API (需要订阅)", + paidModeTitle: "Claude CLI 正在使用官方 Anthropic API", + paidModeDesc: "所有代理配置已移除。Claude CLI 直接使用您的 Anthropic 订阅。", + paidModeHint: "切换到代理模式以配置模型路由和预设。", + modeToggleFailed: "切换模式失败", + screenshotMode: "截图模式", + screenshotModeDesc: "隐藏账户信息,方便安全分享屏幕。", + // Developer Mode + devMode: "开发者模式", + devModeDesc: "启用调试日志、日志导出和策略健康检查。", + devModeToggled: "开发者模式已{status}", + failedToUpdateDevMode: "更新开发者模式失败", + exportLogs: "导出日志", + strategyHealthInspector: "策略健康检查器", + healthScore: "健康分数", + tokenBucket: "令牌", + consecutiveFailures: "失败次数", + refreshHealthData: "刷新", + healthNotAvailable: "健康追踪仅在混合策略下可用。", + // Placeholder Data + debugLogging: "调试日志", + debugLoggingDesc: "在日志查看器中显示详细调试消息。", + logExportToggle: "日志导出", + logExportToggleDesc: "在日志查看器工具栏中显示导出按钮。", + healthInspectorToggle: "健康检查器", + healthInspectorToggleDesc: "在账户视图中显示策略健康检查器。", + placeholderData: "占位数据", + placeholderDataDesc: "注入虚拟账户和配额以进行界面测试。", + includeRealAccounts: "包含真实账户", + includeRealAccountsDesc: "在占位数据旁显示真实账户。", + // 服务器预设配置编辑器 - 新字段 + switchAccountDelay: "切换账户延迟", + capacityBackoffTiers: "容量退避层级", + scoringWeights: "评分权重", + healthScoreSection: "健康分数", + tokenBucketSection: "令牌桶", + quotaAwarenessSection: "配额感知", + hsInitial: "初始分数", + hsSuccessReward: "成功奖励", + hsRateLimitPenalty: "限流惩罚", + hsFailurePenalty: "失败惩罚", + hsRecoveryPerHour: "每小时恢复", + hsMinUsable: "最低可用分数", + hsMaxScore: "最高分数", + tbMaxTokens: "最大令牌数", + tbTokensPerMinute: "每分钟令牌", + tbInitialTokens: "初始令牌", + qLowThreshold: "低阈值", + qCriticalThreshold: "临界阈值", + qStaleMs: "数据过期超时", + weightHealth: "健康权重", + weightTokens: "令牌权重", + weightQuota: "配额权重", + weightLru: "LRU 权重", + // 模型下拉菜单 + clearSelection: "清除选择", +}; diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100755 index 0000000000000000000000000000000000000000..bdfa9c355bc25f85ef20c57eecc360a5fc4baf7e --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,69 @@ +/** + * Utility functions for Antigravity Console + */ + +window.utils = { + // Shared Request Wrapper + async request(url, options = {}, webuiPassword = '') { + options.headers = options.headers || {}; + if (webuiPassword) { + options.headers['x-webui-password'] = webuiPassword; + } + + let response = await fetch(url, options); + + if (response.status === 401) { + const store = Alpine.store('global'); + const password = prompt(store ? store.t('enterPassword') : 'Enter Web UI Password:'); + if (password) { + // Return new password so caller can update state + // This implies we need a way to propagate the new password back + // For simplicity in this functional utility, we might need a callback or state access + // But generally utils shouldn't probably depend on global state directly if possible + // let's stick to the current logic but wrapped + localStorage.setItem('antigravity_webui_password', password); + options.headers['x-webui-password'] = password; + response = await fetch(url, options); + return { response, newPassword: password }; + } + } + + return { response, newPassword: null }; + }, + + formatTimeUntil(isoTime) { + const store = Alpine.store('global'); + const diff = new Date(isoTime) - new Date(); + if (diff <= 0) return store ? store.t('ready') : 'READY'; + const mins = Math.floor(diff / 60000); + const hrs = Math.floor(mins / 60); + + const hSuffix = store ? store.t('timeH') : 'H'; + const mSuffix = store ? store.t('timeM') : 'M'; + + if (hrs > 0) return `${hrs}${hSuffix} ${mins % 60}${mSuffix}`; + return `${mins}${mSuffix}`; + }, + + getThemeColor(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + }, + + /** + * Debounce function - delays execution until after specified wait time + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in milliseconds + * @returns {Function} Debounced function + */ + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } +}; diff --git a/public/js/utils/account-actions.js b/public/js/utils/account-actions.js new file mode 100755 index 0000000000000000000000000000000000000000..ee4b7b8881b01a65d4b2a885466327cf1cabe3e2 --- /dev/null +++ b/public/js/utils/account-actions.js @@ -0,0 +1,189 @@ +/** + * Account Actions Service + * 纯业务逻辑层 - 处理账号操作的 HTTP 请求、乐观更新和数据刷新 + * 不包含 UI 关注点(Toast、Loading、模态框由组件层处理) + */ +window.AccountActions = window.AccountActions || {}; + +/** + * 刷新账号 token 和配额信息 + * @param {string} email - 账号邮箱 + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ +window.AccountActions.refreshAccount = async function(email) { + const store = Alpine.store('global'); + + try { + const { response, newPassword } = await window.utils.request( + `/api/accounts/${encodeURIComponent(email)}/refresh`, + { method: 'POST' }, + store.webuiPassword + ); + + if (newPassword) { + store.webuiPassword = newPassword; + } + + const data = await response.json(); + if (data.status !== 'ok') { + return { success: false, error: data.error || Alpine.store('global').t('refreshFailed') }; + } + + // 触发数据刷新 + await Alpine.store('data').fetchData(); + + return { success: true, data }; + } catch (error) { + return { success: false, error: error.message }; + } +}; + +/** + * 切换账号启用/禁用状态(包含乐观更新和错误回滚) + * @param {string} email - 账号邮箱 + * @param {boolean} enabled - 目标状态(true=启用, false=禁用) + * @returns {Promise<{success: boolean, rolledBack?: boolean, data?: object, error?: string}>} + */ +window.AccountActions.toggleAccount = async function(email, enabled) { + const store = Alpine.store('global'); + const dataStore = Alpine.store('data'); + + // 乐观更新:立即修改 UI + const account = dataStore.accounts.find(a => a.email === email); + const previousState = account ? account.enabled : !enabled; + + if (account) { + account.enabled = enabled; + } + + try { + const { response, newPassword } = await window.utils.request( + `/api/accounts/${encodeURIComponent(email)}/toggle`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }, + store.webuiPassword + ); + + if (newPassword) { + store.webuiPassword = newPassword; + } + + const data = await response.json(); + if (data.status !== 'ok') { + throw new Error(data.error || Alpine.store('global').t('toggleFailed')); + } + + // 确认服务器状态 + await dataStore.fetchData(); + return { success: true, data }; + + } catch (error) { + // 错误回滚:恢复原状态 + if (account) { + account.enabled = previousState; + } + await dataStore.fetchData(); + return { success: false, error: error.message, rolledBack: true }; + } +}; + +/** + * 删除账号 + * @param {string} email - 账号邮箱 + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ +window.AccountActions.deleteAccount = async function(email) { + const store = Alpine.store('global'); + + try { + const { response, newPassword } = await window.utils.request( + `/api/accounts/${encodeURIComponent(email)}`, + { method: 'DELETE' }, + store.webuiPassword + ); + + if (newPassword) { + store.webuiPassword = newPassword; + } + + const data = await response.json(); + if (data.status !== 'ok') { + return { success: false, error: data.error || Alpine.store('global').t('deleteFailed') }; + } + + // 触发数据刷新 + await Alpine.store('data').fetchData(); + return { success: true, data }; + + } catch (error) { + return { success: false, error: error.message }; + } +}; + +/** + * 获取账号重新认证的 OAuth URL + * 注意:此方法仅返回 URL,不打开窗口(由组件层决定如何处理) + * @param {string} email - 账号邮箱 + * @returns {Promise<{success: boolean, url?: string, error?: string}>} + */ +window.AccountActions.getFixAccountUrl = async function(email) { + const store = Alpine.store('global'); + + try { + const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`; + const { response, newPassword } = await window.utils.request( + urlPath, + {}, + store.webuiPassword + ); + + if (newPassword) { + store.webuiPassword = newPassword; + } + + const data = await response.json(); + if (data.status !== 'ok') { + return { success: false, error: data.error || Alpine.store('global').t('authUrlFailed') }; + } + + return { success: true, url: data.url }; + + } catch (error) { + return { success: false, error: error.message }; + } +}; + +/** + * 从磁盘重新加载所有账号配置 + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ +window.AccountActions.reloadAccounts = async function() { + const store = Alpine.store('global'); + + try { + const { response, newPassword } = await window.utils.request( + '/api/accounts/reload', + { method: 'POST' }, + store.webuiPassword + ); + + if (newPassword) { + store.webuiPassword = newPassword; + } + + const data = await response.json(); + if (data.status !== 'ok') { + return { success: false, error: data.error || Alpine.store('global').t('reloadFailed') }; + } + + // 触发数据刷新 + await Alpine.store('data').fetchData(); + return { success: true, data }; + + } catch (error) { + return { success: false, error: error.message }; + } +}; diff --git a/public/js/utils/error-handler.js b/public/js/utils/error-handler.js new file mode 100755 index 0000000000000000000000000000000000000000..401a40b074fa076b4d2d2cab47974d6577122998 --- /dev/null +++ b/public/js/utils/error-handler.js @@ -0,0 +1,96 @@ +/** + * Error Handling Utilities + * Provides standardized error handling with toast notifications + */ +window.ErrorHandler = window.ErrorHandler || {}; + +/** + * Safely execute an async function with error handling + * @param {Function} fn - Async function to execute + * @param {string} errorMessage - User-friendly error message prefix + * @param {object} options - Additional options + * @param {boolean} options.rethrow - Whether to rethrow the error after handling (default: false) + * @param {Function} options.onError - Custom error handler callback + * @returns {Promise} Result of the function or undefined on error + */ +window.ErrorHandler.safeAsync = async function(fn, errorMessage = null, options = {}) { + const { rethrow = false, onError = null } = options; + const store = Alpine.store('global'); + const defaultErrorMessage = errorMessage || store.t('operationFailed'); + + try { + return await fn(); + } catch (error) { + // Log error for debugging + console.error(`[ErrorHandler] ${defaultErrorMessage}:`, error); + + // Show toast notification + const fullMessage = `${defaultErrorMessage}: ${error.message || store.t('unknownError')}`; + store.showToast(fullMessage, 'error'); + + // Call custom error handler if provided + if (onError && typeof onError === 'function') { + try { + onError(error); + } catch (handlerError) { + console.error('[ErrorHandler] Custom error handler failed:', handlerError); + } + } + + // Rethrow if requested + if (rethrow) { + throw error; + } + + return undefined; + } +}; + +/** + * Show an error toast notification + * @param {string} message - Error message + * @param {Error} error - Optional error object + */ +window.ErrorHandler.showError = function(message, error = null) { + const store = Alpine.store('global'); + const fullMessage = error ? `${message}: ${error.message}` : message; + store.showToast(fullMessage, 'error'); +}; + +/** + * Execute an async function with automatic loading state management + * @param {Function} asyncFn - Async function to execute + * @param {object} context - Component context (this) that contains the loading state + * @param {string} loadingKey - Name of the loading state property (default: 'loading') + * @param {object} options - Additional options (same as safeAsync) + * @returns {Promise} Result of the function or undefined on error + * + * @example + * // In your Alpine component: + * async refreshAccount(email) { + * return await window.ErrorHandler.withLoading(async () => { + * const response = await window.utils.request(`/api/accounts/${email}/refresh`, { method: 'POST' }); + * this.$store.global.showToast('Account refreshed', 'success'); + * return response; + * }, this, 'refreshing'); + * } + * + * // In HTML: + * // + */ +window.ErrorHandler.withLoading = async function(asyncFn, context, loadingKey = 'loading', options = {}) { + // Set loading state to true + context[loadingKey] = true; + + try { + // Execute the async function with error handling + const result = await window.ErrorHandler.safeAsync(asyncFn, options.errorMessage, options); + return result; + } finally { + // Always reset loading state, even if there was an error + context[loadingKey] = false; + } +}; diff --git a/public/js/utils/model-config.js b/public/js/utils/model-config.js new file mode 100755 index 0000000000000000000000000000000000000000..0a9577a4b443edf677a4d1de7836cf9d50c8f88a --- /dev/null +++ b/public/js/utils/model-config.js @@ -0,0 +1,42 @@ +/** + * Model Configuration Utilities + * Shared functions for model configuration updates + */ +window.ModelConfigUtils = window.ModelConfigUtils || {}; + +/** + * Update model configuration with authentication and optimistic updates + * @param {string} modelId - The model ID to update + * @param {object} configUpdates - Configuration updates (pinned, hidden, alias, mapping) + * @returns {Promise} + */ +window.ModelConfigUtils.updateModelConfig = async function(modelId, configUpdates) { + return window.ErrorHandler.safeAsync(async () => { + const store = Alpine.store('global'); + + const { response, newPassword } = await window.utils.request('/api/models/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modelId, config: configUpdates }) + }, store.webuiPassword); + + // Update password if server provided a new one + if (newPassword) { + store.webuiPassword = newPassword; + } + + if (!response.ok) { + throw new Error(store.t('failedToUpdateModelConfig')); + } + + // Optimistic update of local state + const dataStore = Alpine.store('data'); + dataStore.modelConfig[modelId] = { + ...dataStore.modelConfig[modelId], + ...configUpdates + }; + + // Recompute quota rows to reflect changes + dataStore.computeQuotaRows(); + }, Alpine.store('global').t('failedToUpdateModelConfig')); +}; diff --git a/public/js/utils/redact.js b/public/js/utils/redact.js new file mode 100755 index 0000000000000000000000000000000000000000..e25e0ae516f311e8a94907bd6c9c395dec4e6adb --- /dev/null +++ b/public/js/utils/redact.js @@ -0,0 +1,31 @@ +/** + * Redact Mode Utility + * Replaces sensitive account data with anonymous labels for screenshots. + */ +window.Redact = { + email(email) { + if (!Alpine.store('settings').redactMode) return email; + if (!email) return email; + const accounts = Alpine.store('data')?.accounts || []; + // Match full email or username-only (split('@')[0]) form + const idx = accounts.findIndex(a => a.email === email || (a.email && a.email.split('@')[0] === email)); + return idx >= 0 ? `Account ${idx + 1}` : 'Account'; + }, + + logMessage(message) { + if (!Alpine.store('settings').redactMode) return message; + const accounts = Alpine.store('data')?.accounts || []; + let result = message; + accounts.forEach((acc, idx) => { + if (!acc.email) return; + const escaped = acc.email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + result = result.replace(new RegExp(escaped, 'g'), `Account ${idx + 1}`); + const user = acc.email.split('@')[0]; + if (user) { + const escapedUser = user.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + result = result.replace(new RegExp(`\\b${escapedUser}\\b`, 'g'), `Account ${idx + 1}`); + } + }); + return result; + } +}; diff --git a/public/js/utils/ui-logger.js b/public/js/utils/ui-logger.js new file mode 100755 index 0000000000000000000000000000000000000000..153a49d9858eca8dad0a92b23fe1d394d1373535 --- /dev/null +++ b/public/js/utils/ui-logger.js @@ -0,0 +1,143 @@ +/** + * UI Logger Utility + * Provides conditional logging for the web UI to reduce console spam in production. + * Wraps console methods and only outputs when debug mode is enabled. + * + * Usage: + * window.UILogger.debug('message') - Only logs if debug mode enabled + * window.UILogger.info('message') - Only logs if debug mode enabled + * window.UILogger.warn('message') - Always logs (important warnings) + * window.UILogger.error('message') - Always logs (errors should always be visible) + * + * Enable debug mode: + * - Set localStorage.setItem('ag_debug', 'true') in browser console + * - Or pass ?debug=true in URL + */ + +(function() { + 'use strict'; + + // Check if debug mode is enabled + function isDebugEnabled() { + // Check URL parameter + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.get('debug') === 'true') { + return true; + } + + // Check localStorage + try { + return localStorage.getItem('ag_debug') === 'true'; + } catch (e) { + return false; + } + } + + // Cache debug state (can be refreshed) + let debugEnabled = isDebugEnabled(); + + window.UILogger = { + /** + * Refresh debug state (call after changing localStorage) + */ + refresh() { + debugEnabled = isDebugEnabled(); + }, + + /** + * Enable debug mode + */ + enableDebug() { + try { + localStorage.setItem('ag_debug', 'true'); + debugEnabled = true; + console.info('[UILogger] Debug mode enabled. Refresh page to see all logs.'); + } catch (e) { + console.warn('[UILogger] Could not save debug preference'); + } + }, + + /** + * Disable debug mode + */ + disableDebug() { + try { + localStorage.removeItem('ag_debug'); + debugEnabled = false; + console.info('[UILogger] Debug mode disabled.'); + } catch (e) { + // Ignore + } + }, + + /** + * Check if debug mode is enabled + * @returns {boolean} + */ + isDebug() { + return debugEnabled; + }, + + /** + * Debug level - only logs if debug mode enabled + * Use for verbose debugging info (chart updates, cache operations, etc.) + */ + debug(...args) { + if (debugEnabled) { + console.log('[DEBUG]', ...args); + } + }, + + /** + * Info level - only logs if debug mode enabled + * Use for informational messages that aren't errors + */ + info(...args) { + if (debugEnabled) { + console.info('[INFO]', ...args); + } + }, + + /** + * Log level - alias for debug + */ + log(...args) { + if (debugEnabled) { + console.log(...args); + } + }, + + /** + * Warn level - always logs + * Use for important warnings that users should see + * But suppress noisy/expected warnings unless in debug mode + */ + warn(...args) { + // In production, only show critical warnings + // In debug mode, show all warnings + if (debugEnabled) { + console.warn(...args); + } + }, + + /** + * Warn level that always shows (for critical warnings) + */ + warnAlways(...args) { + console.warn(...args); + }, + + /** + * Error level - always logs + * Errors should always be visible for debugging + */ + error(...args) { + console.error(...args); + } + }; + + // Log initial state (only in debug mode) + if (debugEnabled) { + console.info('[UILogger] Debug mode is ON. Set localStorage ag_debug=false to disable.'); + } +})(); diff --git a/public/js/utils/validators.js b/public/js/utils/validators.js new file mode 100755 index 0000000000000000000000000000000000000000..948290f64aa4036118bd091a27d5a00341164a94 --- /dev/null +++ b/public/js/utils/validators.js @@ -0,0 +1,77 @@ +/** + * Input Validation Utilities + * Provides validation functions for user inputs + */ +window.Validators = window.Validators || {}; + +/** + * Validate a number is within a range + * @param {number} value - Value to validate + * @param {number} min - Minimum allowed value (inclusive) + * @param {number} max - Maximum allowed value (inclusive) + * @param {string} fieldName - Name of the field for error messages + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateRange = function(value, min, max, fieldName = 'Value') { + const numValue = Number(value); + const t = Alpine.store('global').t; + + if (isNaN(numValue)) { + return { + isValid: false, + value: min, + error: t('mustBeValidNumber', { fieldName }) + }; + } + + if (numValue < min) { + return { + isValid: false, + value: min, + error: t('mustBeAtLeast', { fieldName, min }) + }; + } + + if (numValue > max) { + return { + isValid: false, + value: max, + error: t('mustBeAtMost', { fieldName, max }) + }; + } + + return { + isValid: true, + value: numValue, + error: null + }; +}; + +/** + * Validate a timeout/duration value (in milliseconds) + * @param {number} value - Timeout value in ms + * @param {number} minMs - Minimum allowed timeout (default: from constants) + * @param {number} maxMs - Maximum allowed timeout (default: from constants) + * @returns {object} { isValid: boolean, value: number, error: string|null } + */ +window.Validators.validateTimeout = function(value, minMs = null, maxMs = null) { + const { TIMEOUT_MIN, TIMEOUT_MAX } = window.AppConstants.VALIDATION; + return window.Validators.validateRange(value, minMs ?? TIMEOUT_MIN, maxMs ?? TIMEOUT_MAX, 'Timeout'); +}; + +/** + * Validate and sanitize input with custom validator + * @param {any} value - Value to validate + * @param {Function} validator - Validator function + * @param {boolean} showError - Whether to show error toast (default: true) + * @returns {object} Validation result + */ +window.Validators.validate = function(value, validator, showError = true) { + const result = validator(value); + + if (!result.isValid && showError && result.error) { + window.ErrorHandler.showError(result.error); + } + + return result; +}; diff --git a/public/quota-dashboard.html b/public/quota-dashboard.html new file mode 100755 index 0000000000000000000000000000000000000000..4f5011415db919bda433c87c760afa11bfbcf600 --- /dev/null +++ b/public/quota-dashboard.html @@ -0,0 +1,274 @@ + + + + + + Antigravity Proxy Dashboard + + + +
+
+

🛡️ Antigravity Proxy Dashboard

+
+ + Last updated: --:--:-- • + Auto-refresh in 10s + + +
+
+
Loading dashboard data...
+
+ + + diff --git a/public/views/accounts.html b/public/views/accounts.html new file mode 100755 index 0000000000000000000000000000000000000000..7ab0af2d8912551d9c622c9ed4f5d15e61f8ebdf --- /dev/null +++ b/public/views/accounts.html @@ -0,0 +1,653 @@ +
+ +
+ +
+

+ Account Management +

+
+ + Manage Google Account tokens and authorization states + +
+
+ + +
+ +
+ + + + +
+ + + + + + + + + +
+ +
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + +
EnabledAccount (Email)SourceTierQuotaHealthOperations
+
+ + +
+

+ + + + +

+
+ + +
+
+ + + + +
+ +
+ + + +
+ + +
+

Health tracking only available with hybrid strategy

+
+ + +
+ + + + + + + + + + + + + +
EmailHealth ScoreTokensFailuresStatus
+
+ + +
+

+ Score is adjusted for successes, rate limits, and failures with passive recovery. +
+ Selection uses weighted health, token usage, quota, and recency (LRU). +

+
+ + +
+

Click Refresh to load health data

+
+
+
+
+ + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/public/views/dashboard.html b/public/views/dashboard.html new file mode 100755 index 0000000000000000000000000000000000000000..1313a644a4e126a7bdf5dfcc45ece285b494d3fa --- /dev/null +++ b/public/views/dashboard.html @@ -0,0 +1,518 @@ +
+ +
+ +
+

+ Dashboard +

+
+ + CLAUDE PROXY SYSTEM + +
+
+ + +
+
+ + +
+ Live + • + + +
+
+ + +
+ + + +
+ Claude CLI not configured + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+ +
+
+ +
+ + + + +
+ +
+ +
+ +
+ + + + +
+ +
+ + + +
+
+ +
+
+ + + +
+
+
+
+ + + + +
+
+ +
+
+ + + +
+
+
+
+ + + + +
+
+ +
+
+ + + +
+
+
+
+ + + + +
+
+ + +
+ +
+ +
+
%
+
+
+ + +
+
+ Global Quota +
+ + +
+
+
+
+ Claude + + + + +
+
+
+
+
+ Gemini + + + + +
+
+
+
+
+
+ +
+ +
+
+
+ + + +

Request Volume

+
+ + +
+
+ Total: + +
+
+ Today: + +
+
+ 1H: + +
+
+
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + + + +
+
+
+ + +
+ + + + +
+ + +
+ + + + + + + + + + +
+
+
+
\ No newline at end of file diff --git a/public/views/logs.html b/public/views/logs.html new file mode 100755 index 0000000000000000000000000000000000000000..4c02da72c98ef2b08e1075337b542acd50b50299 --- /dev/null +++ b/public/views/logs.html @@ -0,0 +1,103 @@ +
+
+ +
+ + +
+
+
+
+
+
+ +
+ + +
+ +
+
+ + + +
+ +
+ + + +
+ + +
+ + + + +
+
+ + +
+ + +
+
+ No logs match filter +
+
+
+
diff --git a/public/views/models.html b/public/views/models.html new file mode 100755 index 0000000000000000000000000000000000000000..0a453515068b1de7d12174532ddca610a02cdfa8 --- /dev/null +++ b/public/views/models.html @@ -0,0 +1,434 @@ +
+ +
+ +
+

+ Models +

+
+ + Real-time quota and status for all available models. + +
+
+ + +
+
+ + + +
+ + +
+
+ + +
+
+ +
+ +
+ + + +
+
+ + +
+ + + +
+ + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ Stat +
+
+
+ Model Identity + + + +
+
+
+ Global Quota + + + +
+
+
+ Next Reset + + + +
+
+
+ Account Distribution + + + +
+
Actions +
+
+ + ESTABLISHING + UPLINK... +
+
+
+ + + +

+ NO SIGNAL DETECTED +

+

+ No model data available. Add accounts to see available models and quota information. +

+ +
+
+
+
+
\ No newline at end of file diff --git a/public/views/settings.html b/public/views/settings.html new file mode 100755 index 0000000000000000000000000000000000000000..567b45452cf571273f93512de6b0432436eb764b --- /dev/null +++ b/public/views/settings.html @@ -0,0 +1,1990 @@ +
+ +
+
+
+

+ + + + + System Configuration +

+
+ +
+ + + + +
+
+ + +
+ + +
+
+ +
+ + +
+ + +
+ +
+ + +
+
+ 10s + 300s +
+
+ + +
+ +
+ + +
+
+ 500 + 5000 +
+
+
+ +
+ +
+
+
+
+ Show Exhausted Models + Display models even if they have 0% + remaining quota. +
+ +
+
+ +
+
+
+ Compact Mode + Reduce + padding in tables for higher information density. +
+ +
+
+ +
+
+
+ Show Config Warning + Display a warning on Dashboard when Claude CLI config doesn't match preset. +
+ +
+
+
+
+ + +
+
+ + + + + Settings below directly + modify + ~/.claude/settings.json. + Restart Claude CLI to apply. + +
+ + +
+ +
+
+
+ + + + + +
+ + + + +
+
+ Proxy + + Paid +
+
+
+ + +
+
+ + + +
+

Claude CLI is using the official Anthropic API

+

All proxy configuration has been removed. Claude CLI uses your Anthropic subscription directly.

+

Switch to Proxy mode to configure model routing and presets.

+
+
+
+ + +
+
+
+ + + + +
+ +
+
+ + +
+

Select a preset to load it. Click "Apply to Claude CLI" to save changes.

+
+ + +
+ +
+
+
ANTHROPIC_BASE_URL
+ +
+
+
ANTHROPIC_AUTH_TOKEN
+ +
+
+
+ + +
+ + +
+ +
+ +
+ + +
▼
+ + +
+ +
+ + +
+ +
+ + +
▼
+ + +
+ +
+
+ +
DEFAULT MODEL ALIASES
+ + +
+ +
+ +
+ + +
▼
+ +
+
+ +
+ +
+ + +
▼
+ +
+
+ +
+ +
+ + +
▼
+ +
+
+
+
+ + +
+
+
+ Experimental MCP CLI + + Enables experimental MCP integration for reliable tool usage with reduced context + consumption. + +
+ +
+
+ + +
+
+
+ Gemini 1M Context Mode + + Appends [1m] suffix to Gemini models for 1M context window support. + + + ⚠ Large context may reduce Gemini performance. + +
+ +
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
Configure model + visibility, pinning, and request mapping.
+
Model + mapping: server-side redirection. Claude Code users: see 'Claude CLI' tab for client-side + setup.
+
+
+ Show Hidden + Models + +
+
+ + +
+ + + + + + + + + + + + + + +
Model IDMapping (Target Model ID) + Actions
+ NO MODELS DETECTED +
+
+
+ + +
+ +
+
+
+
+ + + +
+
+

WebUI Password

+

+ Authentication for accessing this dashboard

+
+
+ +
+
+ + +
+
+
+ + + + + +
+ +
+
+ + + + +
+ + + + +

+ Select a preset and click the download icon to apply it. Built-in presets (🔒) cannot be deleted. +

+
+ + + + + + + + +
+
+ Common Settings +
+
+ + +
+
+
+
+ Developer Mode +
+ Enables debug logging, log export, and strategy health inspector +
+ +
+ + +
+ +
+
+ Screenshot Mode + Hide email addresses for safe screen sharing. +
+ +
+ + +
+
+ Debug Logging + Show verbose debug messages in the log viewer. +
+ +
+ + +
+
+ Log Export + Show export button in the log viewer toolbar. +
+ +
+ + +
+
+ Health Inspector + Show strategy health inspector in the accounts view. +
+ +
+ + +
+
+
+ Placeholder Data + Inject dummy accounts and quotas for UI testing. +
+ +
+ + +
+
+
+ Include Real Accounts + Show real accounts alongside placeholder data. +
+ +
+
+
+
+
+ + +
+
+
+ Persist Token Cache + Save OAuth tokens to disk for faster + restarts +
+ +
+
+ + +
+ +
+ + +
+ Maximum number of Google accounts allowed +
+
+ + +
+
+ Account Selection Strategy +
+
+ +
+
+
+ Selection Strategy + How + accounts are selected for requests +
+ +
+
+
+ + +
+
+
+
+ + + +
+
+ Advanced Settings + Settings managed via config.json +
+
+ + + +
+ +
+
+ + +
+
+ Network Retry Settings +
+ +
+ +
+ + +
+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ + +
+
+ Rate Limiting & Timeouts +
+ +
+ +
+ + +
+

Fallback cooldown when API doesn't + provide a reset time.

+
+ +
+ +
+ + +
+

If all accounts are rate-limited longer than + this, error immediately.

+
+
+ + +
+
+ Quota Protection +
+ +
+ +
+ + +
+

Switch accounts when quota drops below this level. Per-account overrides take priority.

+
+
+ + +
+
+ Request Throttling + Experimental +
+ + +
+ + +
+
+ Enable Request Throttling + Add delay before each Google API request to avoid rate limits. +
+ +
+ + +
+
+ +
+ + +
+

+ Milliseconds to wait before each API call (token refresh, quota check, messages, etc).

+
+
+
+
+ + +
+
+ Error Handling Tuning +
+ +
+ +
+ + +
+

Prevents concurrent retry + storms.

+
+ +
+ +
+ + +
+

Failures before extended + cooldown.

+
+ +
+ +
+ + +
+

Applied after max consecutive + failures.

+
+ +
+ +
+ + +
+

Retries before switching + accounts.

+
+
+
+
+ + +
+ + + + All + changes are saved automatically. Some settings may require server restart. +
+ + +
+
+

Change + WebUI Password

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+
+ +
+
+
\ No newline at end of file diff --git a/scripts/acceptance.sh b/scripts/acceptance.sh new file mode 100755 index 0000000000000000000000000000000000000000..3c18aeda17f3e2fe48431601e0b40c7608a4e804 --- /dev/null +++ b/scripts/acceptance.sh @@ -0,0 +1,38 @@ + +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8080}" +: "${PROXY_API_KEY:?Set PROXY_API_KEY before running acceptance tests}" + +request() { + local name="$1" + shift + echo "==> ${name}" + curl --fail-with-body --silent --show-error "$@" + echo +} + +request "health" "${BASE_URL}/health" +request "models" \ + -H "Authorization: Bearer ${PROXY_API_KEY}" \ + "${BASE_URL}/v1/models" +request "non-streaming alias" \ + -H "Authorization: Bearer ${PROXY_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"model":"antigravity/gemini-3-flash","messages":[{"role":"user","content":"Reply with READY only."}],"stream":false}' \ + "${BASE_URL}/v1/chat/completions" +request "reasoning high" \ + -H "Authorization: Bearer ${PROXY_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"model":"antigravity/gemini-3-flash","messages":[{"role":"user","content":"Reply with READY only."}],"reasoning_effort":"high","stream":false}' \ + "${BASE_URL}/v1/chat/completions" + +echo "==> streaming" +curl --fail-with-body --no-buffer --silent --show-error \ + -H "Authorization: Bearer ${PROXY_API_KEY}" \ + -H "Content-Type: application/json" \ + -d '{"model":"antigravity/gemini-3-flash","messages":[{"role":"user","content":"Count from 1 to 3."}],"stream":true}' \ + "${BASE_URL}/v1/chat/completions" +echo + diff --git a/scripts/export-config-env.sh b/scripts/export-config-env.sh new file mode 100755 index 0000000000000000000000000000000000000000..d528e12d07a8a0581c371340175701476fffd1e9 --- /dev/null +++ b/scripts/export-config-env.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Export accounts.json → Hugging Face CONFIG_ENV secret format. +# +# Usage: +# ./scripts/export-config-env.sh +# ./scripts/export-config-env.sh /path/to/accounts.json +# ./scripts/export-config-env.sh /path/to/accounts.json /path/to/output.txt +# +# Default output: /hf-config-env.txt (gitignored) +# Paste that file into HF Space Settings → Secrets → CONFIG_ENV. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +ACCOUNTS_FILE="${1:-${HOME}/.config/antigravity-proxy/accounts.json}" +OUTPUT_FILE="${2:-${REPO_ROOT}/hf-config-env.txt}" + +if [[ ! -f "${ACCOUNTS_FILE}" ]]; then + echo "Error: accounts file not found: ${ACCOUNTS_FILE}" >&2 + echo "Usage: $0 [path/to/accounts.json] [path/to/output.txt]" >&2 + exit 1 +fi + +node --input-type=module - "${ACCOUNTS_FILE}" <<'EOF' > "${OUTPUT_FILE}" +import { readFileSync } from 'fs'; + +const accountsPath = process.argv[2]; +let data; + +try { + data = JSON.parse(readFileSync(accountsPath, 'utf8')); +} catch (error) { + console.error(`Error: failed to read ${accountsPath}: ${error.message}`); + process.exit(1); +} + +const accounts = data.accounts; +if (!Array.isArray(accounts) || accounts.length === 0) { + console.error('Error: no accounts found in file'); + process.exit(1); +} + +const lines = []; + +for (let i = 0; i < accounts.length; i += 1) { + const acc = accounts[i]; + const n = i + 1; + const email = String(acc.email || '').trim(); + const refreshToken = String(acc.refreshToken || '').trim(); + + if (!email || !refreshToken) { + console.error(`Error: account ${n} is missing email or refreshToken`); + process.exit(1); + } + + const parts = refreshToken.split('|'); + const projectId = String( + acc.subscription?.projectId || parts[1] || parts[2] || '' + ).trim(); + + let tier = String(acc.subscription?.tier || '').trim().toLowerCase(); + if (!tier || tier === 'unknown') { + tier = 'pro'; + } + + lines.push(`ANTIGRAVITY_${n}_EMAIL=${email}`); + lines.push(`ANTIGRAVITY_${n}_REFRESH_TOKEN=${refreshToken}`); + if (projectId) { + lines.push(`ANTIGRAVITY_${n}_PROJECT_ID=${projectId}`); + } + lines.push(`ANTIGRAVITY_${n}_TIER=${tier}`); + lines.push(''); +} + +process.stdout.write(lines.join('\n')); +EOF + +chmod 600 "${OUTPUT_FILE}" + +account_count="$(grep -c '^ANTIGRAVITY_[0-9]\+_EMAIL=' "${OUTPUT_FILE}" || true)" +echo "Wrote ${account_count} account(s) to: ${OUTPUT_FILE}" +echo "Paste that file into Hugging Face → Space Settings → Secrets → CONFIG_ENV" +echo "Do not commit this file (listed in .gitignore)." diff --git a/src/account-manager/credentials.js b/src/account-manager/credentials.js new file mode 100755 index 0000000000000000000000000000000000000000..129304f2fbb25a6b1c2fa00b9fb88f5ef2f970df --- /dev/null +++ b/src/account-manager/credentials.js @@ -0,0 +1,372 @@ +/** + * Credentials Management + * + * Handles OAuth token handling and project discovery. + */ + +import { + ANTIGRAVITY_DB_PATH, + TOKEN_REFRESH_INTERVAL_MS, + LOAD_CODE_ASSIST_ENDPOINTS, + LOAD_CODE_ASSIST_HEADERS, + CLIENT_METADATA, + DEFAULT_PROJECT_ID +} from '../constants.js'; +import { refreshAccessToken, parseRefreshParts, formatRefreshParts } from '../auth/oauth.js'; +import { getAuthStatus } from '../auth/database.js'; +import { logger } from '../utils/logger.js'; +import { isNetworkError, throttledFetch } from '../utils/helpers.js'; +import { onboardUser, getDefaultTierId } from './onboarding.js'; +import { parseTierId } from '../cloudcode/model-api.js'; + +// Track accounts currently fetching subscription to avoid duplicate calls +const subscriptionFetchInProgress = new Set(); + +/** + * Fetch subscription tier and save it (blocking) + * Used when we have a cached project but missing subscription data + * + * @param {string} token - OAuth access token + * @param {Object} account - Account object + * @param {Function} [onSave] - Callback to save account changes + */ +async function fetchAndSaveSubscription(token, account, onSave) { + // Avoid duplicate fetches for the same account + if (subscriptionFetchInProgress.has(account.email)) { + return; + } + subscriptionFetchInProgress.add(account.email); + + try { + // Call discoverProject just to get subscription info + const { subscription } = await discoverProject(token, account.projectId); + if (subscription && subscription.tier !== 'unknown') { + account.subscription = subscription; + if (onSave) { + await onSave(); + } + logger.info(`[AccountManager] Updated subscription tier for ${account.email}: ${subscription.tier}`); + } + } catch (e) { + logger.debug(`[AccountManager] Subscription fetch failed for ${account.email}: ${e.message}`); + } finally { + subscriptionFetchInProgress.delete(account.email); + } +} + +/** + * Get OAuth token for an account + * + * @param {Object} account - Account object with email and credentials + * @param {Map} tokenCache - Token cache map + * @param {Function} onInvalid - Callback when account is invalid (email, reason) + * @param {Function} onSave - Callback to save changes + * @returns {Promise} OAuth access token + * @throws {Error} If token refresh fails + */ +export async function getTokenForAccount(account, tokenCache, onInvalid, onSave) { + // Check cache first + const cached = tokenCache.get(account.email); + if (cached && (Date.now() - cached.extractedAt) < TOKEN_REFRESH_INTERVAL_MS) { + return cached.token; + } + + // Get fresh token based on source + let token; + + if (account.source === 'oauth' && account.refreshToken) { + // OAuth account - use refresh token to get new access token + try { + const tokens = await refreshAccessToken(account.refreshToken); + token = tokens.accessToken; + // Clear invalid flag on success + if (account.isInvalid) { + account.isInvalid = false; + account.invalidReason = null; + if (onSave) await onSave(); + } + logger.success(`[AccountManager] Refreshed OAuth token for: ${account.email}`); + } catch (error) { + // Check if it's a transient network error + if (isNetworkError(error)) { + logger.warn(`[AccountManager] Failed to refresh token for ${account.email} due to network error: ${error.message}`); + // Do NOT mark as invalid, just throw so caller knows it failed + throw new Error(`AUTH_NETWORK_ERROR: ${error.message}`); + } + + logger.error(`[AccountManager] Failed to refresh token for ${account.email}:`, error.message); + // Mark account as invalid (credentials need re-auth) + if (onInvalid) onInvalid(account.email, error.message); + throw new Error(`AUTH_INVALID: ${account.email}: ${error.message}`); + } + } else if (account.source === 'manual' && account.apiKey) { + token = account.apiKey; + } else { + // Extract from database + const dbPath = account.dbPath || ANTIGRAVITY_DB_PATH; + const authData = getAuthStatus(dbPath); + token = authData.apiKey; + } + + // Cache the token + tokenCache.set(account.email, { + token, + extractedAt: Date.now() + }); + + return token; +} + +/** + * Get project ID for an account + * Aligned with opencode-antigravity-auth: parses refresh token for stored project IDs + * + * @param {Object} account - Account object + * @param {string} token - OAuth access token + * @param {Map} projectCache - Project cache map + * @param {Function} [onSave] - Callback to save account changes + * @returns {Promise} Project ID + */ +export async function getProjectForAccount(account, token, projectCache, onSave = null) { + // Check cache first + const cached = projectCache.get(account.email); + if (cached) { + return cached; + } + + // Parse refresh token to get stored project IDs (aligned with opencode-antigravity-auth) + const parts = account.refreshToken ? parseRefreshParts(account.refreshToken) : { refreshToken: null, projectId: undefined, managedProjectId: undefined }; + + // If we have a managedProjectId in the refresh token, use it + if (parts.managedProjectId) { + projectCache.set(account.email, parts.managedProjectId); + // If subscription is missing/unknown, fetch it now (blocking) + if (!account.subscription || account.subscription.tier === 'unknown') { + await fetchAndSaveSubscription(token, account, onSave); + } + return parts.managedProjectId; + } + + // Legacy: check account.projectId for backward compatibility + if (account.projectId) { + projectCache.set(account.email, account.projectId); + // If subscription is missing/unknown, fetch it now (blocking) + if (!account.subscription || account.subscription.tier === 'unknown') { + await fetchAndSaveSubscription(token, account, onSave); + } + return account.projectId; + } + + // Discover managed project, passing projectId for metadata.duetProject + // Reference: opencode-antigravity-auth - discoverProject handles fallback internally + const { project, subscription } = await discoverProject(token, parts.projectId); + + // Store managedProjectId back in refresh token (if we got a real project) + if (project && project !== DEFAULT_PROJECT_ID) { + let needsSave = false; + + if (account.refreshToken) { + // OAuth accounts: encode in refresh token + account.refreshToken = formatRefreshParts({ + refreshToken: parts.refreshToken, + projectId: parts.projectId, + managedProjectId: project, + }); + needsSave = true; + } else if (account.source === 'database' || account.source === 'manual') { + // Database/manual accounts: store in projectId field + account.projectId = project; + needsSave = true; + } + + // Save subscription tier if discovered + if (subscription) { + account.subscription = subscription; + needsSave = true; + } + + // Trigger save to persist the updated project and subscription + if (needsSave && onSave) { + try { + await onSave(); + } catch (e) { + logger.warn(`[AccountManager] Failed to save updated project: ${e.message}`); + } + } + } else if (subscription) { + // Even if no project discovered, save subscription if we got it + account.subscription = subscription; + if (onSave) { + try { + await onSave(); + } catch (e) { + logger.warn(`[AccountManager] Failed to save subscription: ${e.message}`); + } + } + } + + projectCache.set(account.email, project); + return project; +} + +/** + * Discover project ID via Cloud Code API + * + * @param {string} token - OAuth access token + * @param {string} [projectId] - Optional project ID from refresh token (for metadata.duetProject) + * @returns {Promise<{project: string, subscription: {tier: string, projectId: string|null, detectedAt: string}|null}>} Project and subscription info + */ +export async function discoverProject(token, projectId = undefined) { + let lastError = null; + let gotSuccessfulResponse = false; + let loadCodeAssistData = null; + + const metadata = { ...CLIENT_METADATA }; + if (projectId) { + metadata.duetProject = projectId; + } + + for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { + try { + const response = await throttledFetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...LOAD_CODE_ASSIST_HEADERS + }, + body: JSON.stringify({ metadata, mode: 1 }) + }); + + if (!response.ok) { + const errorText = await response.text(); + lastError = `${response.status} - ${errorText}`; + logger.debug(`[AccountManager] loadCodeAssist failed at ${endpoint}: ${lastError}`); + continue; + } + + const data = await response.json(); + gotSuccessfulResponse = true; + loadCodeAssistData = data; + + logger.debug(`[AccountManager] loadCodeAssist response from ${endpoint}:`, JSON.stringify(data)); + + // Extract subscription tier from response + const subscription = extractSubscriptionFromResponse(data); + + if (typeof data.cloudaicompanionProject === 'string') { + logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject}`); + return { project: data.cloudaicompanionProject, subscription }; + } + if (data.cloudaicompanionProject?.id) { + logger.success(`[AccountManager] Discovered project: ${data.cloudaicompanionProject.id}`); + return { project: data.cloudaicompanionProject.id, subscription }; + } + + // No project found - log tier data and try to onboard the user + logger.info(`[AccountManager] No project in loadCodeAssist response, attempting onboardUser...`); + logger.debug(`[AccountManager] Tier data for onboarding: paidTier=${JSON.stringify(data.paidTier)}, currentTier=${JSON.stringify(data.currentTier)}, allowedTiers=${JSON.stringify(data.allowedTiers?.map(t => ({ id: t?.id, isDefault: t?.isDefault })))}`); + break; + } catch (error) { + lastError = error.message; + logger.debug(`[AccountManager] loadCodeAssist error at ${endpoint}:`, error.message); + } + } + + // If we got a successful response but no project, try onboarding + if (gotSuccessfulResponse && loadCodeAssistData) { + // Only use allowedTiers for onboarding (matching opencode-antigravity-auth and oauth.js) + // Note: paidTier (g1-pro-tier, g1-ultra-tier) is NOT valid for onboardUser API + // The paidTier is used for subscription detection only, not for onboarding + const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'free-tier'; + logger.info(`[AccountManager] Onboarding user with tier: ${tierId}`); + + // Pass projectId for metadata.duetProject (without fallback, matching reference) + // Reference: opencode-antigravity-auth passes parts.projectId (not fallback) to onboardManagedProject + const onboardedProject = await onboardUser( + token, + tierId, + projectId // Original projectId without fallback + ); + if (onboardedProject) { + logger.success(`[AccountManager] Successfully onboarded, project: ${onboardedProject}`); + const subscription = extractSubscriptionFromResponse(loadCodeAssistData); + return { project: onboardedProject, subscription }; + } + + logger.warn(`[AccountManager] Onboarding failed - account may not work correctly`); + } + + // Only warn if all endpoints failed with errors (not just missing project) + if (!gotSuccessfulResponse) { + logger.warn(`[AccountManager] loadCodeAssist failed for all endpoints: ${lastError}`); + } + + // Fallback: use projectId if available, otherwise use default + // Reference: opencode-antigravity-auth/src/plugin/project.ts + if (projectId) { + return { project: projectId, subscription: null }; + } + return { project: DEFAULT_PROJECT_ID, subscription: null }; +} + +/** + * Extract subscription tier from loadCodeAssist response + * + * @param {Object} data - loadCodeAssist response data + * @returns {{tier: string, projectId: string|null, detectedAt: string}|null} Subscription info + */ +function extractSubscriptionFromResponse(data) { + if (!data) return null; + + // Priority: paidTier > currentTier (consistent with model-api.js) + let tier = 'free'; + let cloudProject = null; + + if (data.paidTier?.id) { + tier = parseTierId(data.paidTier.id); + } else if (data.currentTier?.id) { + tier = parseTierId(data.currentTier.id); + } + + // Get project ID + if (typeof data.cloudaicompanionProject === 'string') { + cloudProject = data.cloudaicompanionProject; + } else if (data.cloudaicompanionProject?.id) { + cloudProject = data.cloudaicompanionProject.id; + } + + return { + tier, + projectId: cloudProject, + detectedAt: new Date().toISOString() + }; +} + +/** + * Clear project cache for an account + * + * @param {Map} projectCache - Project cache map + * @param {string|null} email - Email to clear cache for, or null to clear all + */ +export function clearProjectCache(projectCache, email = null) { + if (email) { + projectCache.delete(email); + } else { + projectCache.clear(); + } +} + +/** + * Clear token cache for an account + * + * @param {Map} tokenCache - Token cache map + * @param {string|null} email - Email to clear cache for, or null to clear all + */ +export function clearTokenCache(tokenCache, email = null) { + if (email) { + tokenCache.delete(email); + } else { + tokenCache.clear(); + } +} diff --git a/src/account-manager/index.js b/src/account-manager/index.js new file mode 100755 index 0000000000000000000000000000000000000000..0f50c12ea2284bc5c7a068ed5d97c0fb01474fcb --- /dev/null +++ b/src/account-manager/index.js @@ -0,0 +1,533 @@ +/** + * Account Manager + * Manages multiple Antigravity accounts with configurable selection strategies, + * automatic failover, and smart cooldown for rate-limited accounts. + */ + +import { ACCOUNT_CONFIG_PATH } from '../constants.js'; +import { config } from '../config.js'; +import { loadAccounts, loadDefaultAccount, saveAccounts } from './storage.js'; +import { + isAllRateLimited as checkAllRateLimited, + getAvailableAccounts as getAvailable, + getInvalidAccounts as getInvalid, + clearExpiredLimits as clearLimits, + resetAllRateLimits as resetLimits, + markRateLimited as markLimited, + markInvalid as markAccountInvalid, + clearInvalid as clearAccountInvalid, + getMinWaitTimeMs as getMinWait, + getRateLimitInfo as getLimitInfo, + getConsecutiveFailures as getFailures, + resetConsecutiveFailures as resetFailures, + incrementConsecutiveFailures as incrementFailures, + markAccountCoolingDown as markCoolingDown, + isAccountCoolingDown as checkCoolingDown, + clearAccountCooldown as clearCooldown, + getCooldownRemaining as getCooldownMs, + CooldownReason +} from './rate-limits.js'; +import { + getTokenForAccount as fetchToken, + getProjectForAccount as fetchProject, + clearProjectCache as clearProject, + clearTokenCache as clearToken +} from './credentials.js'; +import { createStrategy, getStrategyLabel, DEFAULT_STRATEGY } from './strategies/index.js'; +import { logger } from '../utils/logger.js'; +import { bootstrapEnvAccounts } from '../bootstrap/env-accounts.js'; + +export class AccountManager { + #accounts = []; + #currentIndex = 0; + #configPath; + #settings = {}; + #initialized = false; + #strategy = null; + #strategyName = DEFAULT_STRATEGY; + + // Per-account caches + #tokenCache = new Map(); // email -> { token, extractedAt } + #projectCache = new Map(); // email -> projectId + + constructor(configPath = ACCOUNT_CONFIG_PATH, strategyName = null) { + this.#configPath = configPath; + // Strategy name can be set at construction or later via initialize + if (strategyName) { + this.#strategyName = strategyName; + } + } + + /** + * Initialize the account manager by loading config + * @param {string} [strategyOverride] - Override strategy name (from CLI flag or env var) + */ + async initialize(strategyOverride = null) { + if (this.#initialized) return; + + await bootstrapEnvAccounts(this.#configPath); + const { accounts, settings, activeIndex } = await loadAccounts(this.#configPath); + + this.#accounts = accounts; + this.#settings = settings; + this.#currentIndex = activeIndex; + + // If config exists but has no accounts, fall back to Antigravity database + if (this.#accounts.length === 0) { + logger.warn('[AccountManager] No accounts in config. Falling back to Antigravity database'); + const { accounts: defaultAccounts, tokenCache } = loadDefaultAccount(); + this.#accounts = defaultAccounts; + this.#tokenCache = tokenCache; + } + + // Determine strategy: CLI override > env var > config file > default + const configStrategy = config?.accountSelection?.strategy; + const envStrategy = process.env.ACCOUNT_STRATEGY; + this.#strategyName = strategyOverride || envStrategy || configStrategy || this.#strategyName; + + // Create the strategy instance + const strategyConfig = config?.accountSelection || {}; + this.#strategy = createStrategy(this.#strategyName, strategyConfig); + logger.info(`[AccountManager] Using ${getStrategyLabel(this.#strategyName)} selection strategy`); + + // Clear any expired rate limits + this.clearExpiredLimits(); + + this.#initialized = true; + } + + /** + * Reload accounts from disk (force re-initialization) + * Useful when accounts.json is modified externally (e.g., by WebUI) + */ + async reload() { + this.#initialized = false; + await this.initialize(); + logger.info('[AccountManager] Accounts reloaded from disk'); + } + + /** + * Get the number of accounts + * @returns {number} Number of configured accounts + */ + getAccountCount() { + return this.#accounts.length; + } + + /** + * Check if all accounts are rate-limited + * @param {string} [modelId] - Optional model ID + * @returns {boolean} True if all accounts are rate-limited + */ + isAllRateLimited(modelId = null) { + return checkAllRateLimited(this.#accounts, modelId); + } + + /** + * Get list of available (non-rate-limited, non-invalid) accounts + * @param {string} [modelId] - Optional model ID + * @returns {Array} Array of available account objects + */ + getAvailableAccounts(modelId = null) { + return getAvailable(this.#accounts, modelId); + } + + /** + * Get list of invalid accounts + * @returns {Array} Array of invalid account objects + */ + getInvalidAccounts() { + return getInvalid(this.#accounts); + } + + /** + * Check if all enabled accounts are invalid (need user intervention). + * Unlike rate limits, invalid accounts won't self-recover — waiting is pointless. + * @returns {boolean} True if every enabled account is invalid + */ + isAllAccountsInvalid() { + const enabled = this.#accounts.filter(a => a.enabled !== false); + return enabled.length > 0 && enabled.every(a => a.isInvalid); + } + + /** + * Clear expired rate limits + * @returns {number} Number of rate limits cleared + */ + clearExpiredLimits() { + const cleared = clearLimits(this.#accounts); + if (cleared > 0) { + this.saveToDisk(); + } + return cleared; + } + + /** + * Clear all rate limits to force a fresh check + * (Optimistic retry strategy) + * @returns {void} + */ + resetAllRateLimits() { + resetLimits(this.#accounts); + } + + /** + * Select an account using the configured strategy. + * This is the main method to use for account selection. + * @param {string} [modelId] - Model ID for the request + * @param {Object} [options] - Additional options + * @param {string} [options.sessionId] - Session ID for cache continuity + * @returns {{account: Object|null, waitMs: number}} Account to use and optional wait time + */ + selectAccount(modelId = null, options = {}) { + if (!this.#strategy) { + throw new Error('AccountManager not initialized. Call initialize() first.'); + } + + const result = this.#strategy.selectAccount(this.#accounts, modelId, { + currentIndex: this.#currentIndex, + onSave: () => this.saveToDisk(), + ...options + }); + + this.#currentIndex = result.index; + return { account: result.account, waitMs: result.waitMs || 0 }; + } + + /** + * Notify the strategy of a successful request + * @param {Object} account - The account that was used + * @param {string} modelId - The model ID that was used + */ + notifySuccess(account, modelId) { + if (this.#strategy) { + this.#strategy.onSuccess(account, modelId); + } + // Reset consecutive failures on success (matches opencode-antigravity-auth) + if (account?.email) { + resetFailures(this.#accounts, account.email); + } + } + + /** + * Notify the strategy of a rate limit + * @param {Object} account - The account that was rate-limited + * @param {string} modelId - The model ID that was rate-limited + */ + notifyRateLimit(account, modelId) { + if (this.#strategy) { + this.#strategy.onRateLimit(account, modelId); + } + } + + /** + * Notify the strategy of a failure + * @param {Object} account - The account that failed + * @param {string} modelId - The model ID that failed + */ + notifyFailure(account, modelId) { + if (this.#strategy) { + this.#strategy.onFailure(account, modelId); + } + } + + /** + * Get the consecutive failure count for an account + * Used for progressive backoff calculation + * @param {string} email - Account email + * @returns {number} Number of consecutive failures + */ + getConsecutiveFailures(email) { + return getFailures(this.#accounts, email); + } + + /** + * Increment the consecutive failure count without marking as rate limited + * Used for quick retries to track failures while staying on same account + * @param {string} email - Account email + * @returns {number} New consecutive failure count + */ + incrementConsecutiveFailures(email) { + return incrementFailures(this.#accounts, email); + } + + /** + * Get the current strategy name + * @returns {string} Strategy name + */ + getStrategyName() { + return this.#strategyName; + } + + /** + * Get the strategy display label + * @returns {string} Strategy display label + */ + getStrategyLabel() { + return getStrategyLabel(this.#strategyName); + } + + /** + * Get the health tracker from the current strategy (if available) + * Used by handlers for consecutive failure tracking + * Only available when using hybrid strategy + * @returns {Object|null} Health tracker instance or null if not available + */ + getHealthTracker() { + if (this.#strategy && typeof this.#strategy.getHealthTracker === 'function') { + return this.#strategy.getHealthTracker(); + } + return null; + } + + /** + * Mark an account as rate-limited + * @param {string} email - Email of the account to mark + * @param {number|null} resetMs - Time in ms until rate limit resets (optional) + * @param {string} [modelId] - Optional model ID to mark specific limit + */ + markRateLimited(email, resetMs = null, modelId = null) { + markLimited(this.#accounts, email, resetMs, modelId); + this.saveToDisk(); + } + + /** + * Mark an account as invalid (credentials need re-authentication) + * @param {string} email - Email of the account to mark + * @param {string} reason - Reason for marking as invalid + * @param {string|null} verifyUrl - Optional verification URL (for 403 VALIDATION_REQUIRED) + */ + markInvalid(email, reason = 'Unknown error', verifyUrl = null) { + markAccountInvalid(this.#accounts, email, reason, verifyUrl); + this.saveToDisk(); + } + + /** + * Clear invalid status for an account (after user completes verification) + * @param {string} email - Email of the account to clear + */ + clearInvalid(email) { + clearAccountInvalid(this.#accounts, email); + this.saveToDisk(); + } + + /** + * Get the minimum wait time until any account becomes available + * @param {string} [modelId] - Optional model ID + * @returns {number} Wait time in milliseconds + */ + getMinWaitTimeMs(modelId = null) { + return getMinWait(this.#accounts, modelId); + } + + /** + * Get rate limit info for a specific account and model + * @param {string} email - Email of the account + * @param {string} modelId - Model ID to check + * @returns {{isRateLimited: boolean, actualResetMs: number|null, waitMs: number}} Rate limit info + */ + getRateLimitInfo(email, modelId) { + return getLimitInfo(this.#accounts, email, modelId); + } + + // ============================================================================ + // Cooldown Methods (matches opencode-antigravity-auth) + // ============================================================================ + + /** + * Mark an account as cooling down for a specified duration + * Used for temporary backoff separate from rate limits + * @param {string} email - Email of the account + * @param {number} cooldownMs - Duration of cooldown in milliseconds + * @param {string} [reason] - Reason for the cooldown (use CooldownReason constants) + */ + markAccountCoolingDown(email, cooldownMs, reason = CooldownReason.RATE_LIMIT) { + markCoolingDown(this.#accounts, email, cooldownMs, reason); + } + + /** + * Check if an account is currently cooling down + * @param {string} email - Email of the account + * @returns {boolean} True if account is cooling down + */ + isAccountCoolingDown(email) { + const account = this.#accounts.find(a => a.email === email); + return account ? checkCoolingDown(account) : false; + } + + /** + * Clear the cooldown for an account + * @param {string} email - Email of the account + */ + clearAccountCooldown(email) { + const account = this.#accounts.find(a => a.email === email); + if (account) { + clearCooldown(account); + } + } + + /** + * Get time remaining until cooldown expires for an account + * @param {string} email - Email of the account + * @returns {number} Milliseconds until cooldown expires, 0 if not cooling down + */ + getCooldownRemaining(email) { + const account = this.#accounts.find(a => a.email === email); + return account ? getCooldownMs(account) : 0; + } + + /** + * Get OAuth token for an account + * @param {Object} account - Account object with email and credentials + * @returns {Promise} OAuth access token + * @throws {Error} If token refresh fails + */ + async getTokenForAccount(account) { + return fetchToken( + account, + this.#tokenCache, + (email, reason) => this.markInvalid(email, reason), + () => this.saveToDisk() + ); + } + + /** + * Get project ID for an account + * @param {Object} account - Account object + * @param {string} token - OAuth access token + * @returns {Promise} Project ID + */ + async getProjectForAccount(account, token) { + // Pass onSave callback to persist managedProjectId in refresh token + return fetchProject(account, token, this.#projectCache, () => this.saveToDisk()); + } + + /** + * Clear project cache for an account (useful on auth errors) + * @param {string|null} email - Email to clear cache for, or null to clear all + */ + clearProjectCache(email = null) { + clearProject(this.#projectCache, email); + } + + /** + * Clear token cache for an account (useful on auth errors) + * @param {string|null} email - Email to clear cache for, or null to clear all + */ + clearTokenCache(email = null) { + clearToken(this.#tokenCache, email); + } + + /** + * Save current state to disk (async) + * @returns {Promise} + */ + async saveToDisk() { + await saveAccounts(this.#configPath, this.#accounts, this.#settings, this.#currentIndex); + } + + /** + * Get status object for logging/API + * @returns {{accounts: Array, settings: Object}} Status object with accounts and settings + */ + getStatus() { + const available = this.getAvailableAccounts(); + const invalid = this.getInvalidAccounts(); + + // Count accounts that have any active model-specific rate limits + const rateLimited = this.#accounts.filter(a => { + if (!a.modelRateLimits) return false; + return Object.values(a.modelRateLimits).some( + limit => limit.isRateLimited && limit.resetTime > Date.now() + ); + }); + + return { + total: this.#accounts.length, + available: available.length, + rateLimited: rateLimited.length, + invalid: invalid.length, + summary: `${this.#accounts.length} total, ${available.length} available, ${rateLimited.length} rate-limited, ${invalid.length} invalid`, + accounts: this.#accounts.map(a => ({ + email: a.email, + source: a.source, + enabled: a.enabled !== false, // Default to true if undefined + projectId: a.projectId || null, + modelRateLimits: a.modelRateLimits || {}, + isInvalid: a.isInvalid || false, + invalidReason: a.invalidReason || null, + verifyUrl: a.verifyUrl || null, + lastUsed: a.lastUsed, + // Include quota threshold settings + quotaThreshold: a.quotaThreshold, + modelQuotaThresholds: a.modelQuotaThresholds || {} + })) + }; + } + + /** + * Get settings + * @returns {Object} Current settings object + */ + getSettings() { + return { ...this.#settings }; + } + + /** + * Get strategy health data for the health inspector panel. + * Only returns tracker data when using the hybrid strategy. + * @returns {Object} Strategy health data + */ + getStrategyHealthData() { + const strategyName = this.#strategyName; + + // Only hybrid strategy has trackers + if (!this.#strategy || typeof this.#strategy.getHealthTracker !== 'function') { + return { strategy: strategyName, trackers: null }; + } + + const healthTracker = this.#strategy.getHealthTracker(); + const tokenBucketTracker = this.#strategy.getTokenBucketTracker(); + + const accounts = this.#accounts + .filter(a => a.enabled !== false) + .map(account => { + const email = account.email; + const healthScore = healthTracker ? healthTracker.getScore(email) : null; + const isUsable = healthTracker ? healthTracker.isUsable(email) : null; + const consecutiveFailures = healthTracker ? healthTracker.getConsecutiveFailures(email) : 0; + const tokens = tokenBucketTracker ? tokenBucketTracker.getTokens(email) : null; + const hasTokens = tokenBucketTracker ? tokenBucketTracker.hasTokens(email) : null; + const maxTokens = tokenBucketTracker ? tokenBucketTracker.getMaxTokens() : null; + + return { + email, + healthScore: healthScore != null ? Math.round(healthScore * 10) / 10 : null, + isUsable, + consecutiveFailures, + tokens: tokens != null ? Math.round(tokens * 10) / 10 : null, + hasTokens, + maxTokens + }; + }); + + return { + strategy: strategyName, + trackers: { accounts } + }; + } + + /** + * Get all accounts (internal use for quota fetching) + * Returns the full account objects including credentials + * @returns {Array} Array of account objects + */ + getAllAccounts() { + return this.#accounts; + } +} + +// Re-export CooldownReason for use by handlers +export { CooldownReason }; + +export default AccountManager; diff --git a/src/account-manager/onboarding.js b/src/account-manager/onboarding.js new file mode 100755 index 0000000000000000000000000000000000000000..3353fb54c2b7c8301e33f6f7724c50b482c50f4e --- /dev/null +++ b/src/account-manager/onboarding.js @@ -0,0 +1,109 @@ +/** + * User Onboarding + * + * Handles provisioning of managed projects for accounts that don't have one. + */ + +import { + ONBOARD_USER_ENDPOINTS, + ANTIGRAVITY_HEADERS, + CLIENT_METADATA +} from '../constants.js'; +import { logger } from '../utils/logger.js'; +import { sleep, throttledFetch } from '../utils/helpers.js'; + +/** + * Get the default tier ID from allowed tiers list + * + * @param {Array} allowedTiers - List of allowed tiers from loadCodeAssist + * @returns {string|undefined} Default tier ID + */ +export function getDefaultTierId(allowedTiers) { + if (!allowedTiers || allowedTiers.length === 0) { + return undefined; + } + + // Find the tier marked as default + for (const tier of allowedTiers) { + if (tier?.isDefault) { + return tier.id; + } + } + + // Fall back to first tier + return allowedTiers[0]?.id; +} + +/** + * Onboard a user to get a managed project + * + * @param {string} token - OAuth access token + * @param {string} tierId - Tier ID (raw API value, e.g., 'free-tier', 'standard-tier', 'g1-pro-tier') + * @param {string} [projectId] - Optional GCP project ID (required for non-free tiers) + * @param {number} [maxAttempts=10] - Maximum polling attempts + * @param {number} [delayMs=5000] - Delay between polling attempts + * @returns {Promise} Managed project ID or null if failed + */ +export async function onboardUser(token, tierId, projectId = undefined, maxAttempts = 10, delayMs = 5000) { + const metadata = { ...CLIENT_METADATA }; + + if (projectId) { + metadata.duetProject = projectId; + } + + const requestBody = { + tierId, + metadata + }; + // Note: Do NOT add cloudaicompanionProject to requestBody + // Reference implementation only sets metadata.duetProject, not the body field + // Adding cloudaicompanionProject causes 400 errors for auto-provisioned tiers (g1-pro, g1-ultra) + + logger.debug(`[Onboarding] Starting onboard with tierId: ${tierId}, projectId: ${projectId}`); + + for (const endpoint of ONBOARD_USER_ENDPOINTS) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + const response = await throttledFetch(`${endpoint}/v1internal:onboardUser`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.warn(`[Onboarding] onboardUser failed at ${endpoint}: ${response.status} - ${errorText}`); + break; // Try next endpoint + } + + const data = await response.json(); + logger.debug(`[Onboarding] onboardUser response (attempt ${attempt + 1}):`, JSON.stringify(data)); + + // Check if onboarding is complete + const managedProjectId = data.response?.cloudaicompanionProject?.id; + if (data.done && managedProjectId) { + return managedProjectId; + } + if (data.done && projectId) { + return projectId; + } + + // Not done yet, wait and retry + if (attempt < maxAttempts - 1) { + logger.debug(`[Onboarding] onboardUser not complete, waiting ${delayMs}ms...`); + await sleep(delayMs); + } + } catch (error) { + logger.warn(`[Onboarding] onboardUser error at ${endpoint}:`, error.message); + break; // Try next endpoint + } + } + } + + logger.warn(`[Onboarding] All onboarding attempts failed for tierId: ${tierId}`); + return null; +} diff --git a/src/account-manager/rate-limits.js b/src/account-manager/rate-limits.js new file mode 100755 index 0000000000000000000000000000000000000000..e112d02cd2781dc4b400a5ebf054b26c5c741dc3 --- /dev/null +++ b/src/account-manager/rate-limits.js @@ -0,0 +1,395 @@ +/** + * Rate Limit Management + * + * Handles rate limit tracking and state management for accounts. + * All rate limits are model-specific. + */ + +import { DEFAULT_COOLDOWN_MS } from '../constants.js'; +import { formatDuration } from '../utils/helpers.js'; +import { logger } from '../utils/logger.js'; + +/** + * Check if all accounts are rate-limited for a specific model + * + * @param {Array} accounts - Array of account objects + * @param {string} modelId - Model ID to check rate limits for + * @returns {boolean} True if all accounts are rate-limited + */ +export function isAllRateLimited(accounts, modelId) { + if (accounts.length === 0) return true; + if (!modelId) return false; // No model specified = not rate limited + + return accounts.every(acc => { + if (acc.isInvalid) return true; // Invalid accounts count as unavailable + if (acc.enabled === false) return true; // Disabled accounts count as unavailable + const modelLimits = acc.modelRateLimits || {}; + const limit = modelLimits[modelId]; + return limit && limit.isRateLimited && limit.resetTime > Date.now(); + }); +} + +/** + * Get list of available (non-rate-limited, non-invalid) accounts for a model + * + * @param {Array} accounts - Array of account objects + * @param {string} [modelId] - Model ID to filter by + * @returns {Array} Array of available account objects + */ +export function getAvailableAccounts(accounts, modelId = null) { + return accounts.filter(acc => { + if (acc.isInvalid) return false; + + // WebUI: Skip disabled accounts + if (acc.enabled === false) return false; + + if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) { + const limit = acc.modelRateLimits[modelId]; + if (limit.isRateLimited && limit.resetTime > Date.now()) { + return false; + } + } + + return true; + }); +} + +/** + * Get list of invalid accounts + * + * @param {Array} accounts - Array of account objects + * @returns {Array} Array of invalid account objects + */ +export function getInvalidAccounts(accounts) { + return accounts.filter(acc => acc.isInvalid); +} + +/** + * Clear expired rate limits + * + * @param {Array} accounts - Array of account objects + * @returns {number} Number of rate limits cleared + */ +export function clearExpiredLimits(accounts) { + const now = Date.now(); + let cleared = 0; + + for (const account of accounts) { + if (account.modelRateLimits) { + for (const [modelId, limit] of Object.entries(account.modelRateLimits)) { + if (limit.isRateLimited && limit.resetTime <= now) { + limit.isRateLimited = false; + limit.resetTime = null; + cleared++; + logger.success(`[AccountManager] Rate limit expired for: ${account.email} (model: ${modelId})`); + } + } + } + } + + return cleared; +} + +/** + * Clear all rate limits to force a fresh check (optimistic retry strategy) + * + * @param {Array} accounts - Array of account objects + */ +export function resetAllRateLimits(accounts) { + for (const account of accounts) { + if (account.modelRateLimits) { + for (const key of Object.keys(account.modelRateLimits)) { + account.modelRateLimits[key] = { isRateLimited: false, resetTime: null }; + } + } + } + logger.warn('[AccountManager] Reset all rate limits for optimistic retry'); +} + +/** + * Mark an account as rate-limited for a specific model + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account to mark + * @param {number|null} resetMs - Time in ms until rate limit resets (from API) + * @param {string} modelId - Model ID to mark rate limit for + * @returns {boolean} True if account was found and marked + */ +export function markRateLimited(accounts, email, resetMs = null, modelId) { + const account = accounts.find(a => a.email === email); + if (!account) return false; + + // Store the ACTUAL reset time from the API + // This is used to decide whether to wait (short) or switch accounts (long) + const actualResetMs = (resetMs && resetMs > 0) ? resetMs : DEFAULT_COOLDOWN_MS; + + if (!account.modelRateLimits) { + account.modelRateLimits = {}; + } + + account.modelRateLimits[modelId] = { + isRateLimited: true, + resetTime: Date.now() + actualResetMs, // Actual reset time for decisions + actualResetMs: actualResetMs // Original duration from API + }; + + // Track consecutive failures for progressive backoff (matches opencode-antigravity-auth) + account.consecutiveFailures = (account.consecutiveFailures || 0) + 1; + + // Log appropriately based on duration + if (actualResetMs > DEFAULT_COOLDOWN_MS) { + logger.warn( + `[AccountManager] Quota exhausted: ${email} (model: ${modelId}). Resets in ${formatDuration(actualResetMs)}` + ); + } else { + logger.warn( + `[AccountManager] Rate limited: ${email} (model: ${modelId}). Available in ${formatDuration(actualResetMs)}` + ); + } + + return true; +} + +/** + * Mark an account as invalid (credentials need re-authentication) + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account to mark + * @param {string} reason - Reason for marking as invalid + * @param {string|null} verifyUrl - Optional verification URL (for 403 VALIDATION_REQUIRED) + * @returns {boolean} True if account was found and marked + */ +export function markInvalid(accounts, email, reason = 'Unknown error', verifyUrl = null) { + const account = accounts.find(a => a.email === email); + if (!account) return false; + + account.isInvalid = true; + account.invalidReason = reason; + account.invalidAt = Date.now(); + account.verifyUrl = verifyUrl || null; + + logger.error( + `[AccountManager] ⚠ Account INVALID: ${email}` + ); + logger.error( + `[AccountManager] Reason: ${reason}` + ); + if (verifyUrl) { + logger.error( + `[AccountManager] Verification URL: ${verifyUrl}` + ); + } + logger.error( + `[AccountManager] Run 'npm run accounts' to re-authenticate this account` + ); + + return true; +} + +/** + * Clear invalid status for an account (after user completes verification) + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account to clear + * @returns {boolean} True if account was found and cleared + */ +export function clearInvalid(accounts, email) { + const account = accounts.find(a => a.email === email); + if (!account) return false; + + account.isInvalid = false; + account.invalidReason = null; + account.verifyUrl = null; + + logger.info(`[AccountManager] ✓ Account re-enabled: ${email}`); + return true; +} + +/** + * Get the minimum wait time until any account becomes available for a model + * + * @param {Array} accounts - Array of account objects + * @param {string} modelId - Model ID to check + * @returns {number} Wait time in milliseconds + */ +export function getMinWaitTimeMs(accounts, modelId) { + if (!isAllRateLimited(accounts, modelId)) return 0; + + const now = Date.now(); + let minWait = Infinity; + let soonestAccount = null; + + for (const account of accounts) { + if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) { + const limit = account.modelRateLimits[modelId]; + if (limit.isRateLimited && limit.resetTime) { + const wait = limit.resetTime - now; + if (wait > 0 && wait < minWait) { + minWait = wait; + soonestAccount = account; + } + } + } + } + + if (soonestAccount) { + logger.info(`[AccountManager] Shortest wait: ${formatDuration(minWait)} (account: ${soonestAccount.email})`); + } + + return minWait === Infinity ? DEFAULT_COOLDOWN_MS : minWait; +} + +/** + * Get the rate limit info for a specific account and model + * Returns the actual reset time from API, not capped + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account + * @param {string} modelId - Model ID to check + * @returns {{isRateLimited: boolean, actualResetMs: number|null, waitMs: number}} Rate limit info + */ +export function getRateLimitInfo(accounts, email, modelId) { + const account = accounts.find(a => a.email === email); + if (!account || !account.modelRateLimits || !account.modelRateLimits[modelId]) { + return { isRateLimited: false, actualResetMs: null, waitMs: 0 }; + } + + const limit = account.modelRateLimits[modelId]; + const now = Date.now(); + const waitMs = limit.resetTime ? Math.max(0, limit.resetTime - now) : 0; + + return { + isRateLimited: limit.isRateLimited && waitMs > 0, + actualResetMs: limit.actualResetMs || null, + waitMs + }; +} + +/** + * Get the consecutive failure count for an account + * Used for progressive backoff calculation (matches opencode-antigravity-auth) + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account + * @returns {number} Number of consecutive failures + */ +export function getConsecutiveFailures(accounts, email) { + const account = accounts.find(a => a.email === email); + return account?.consecutiveFailures || 0; +} + +/** + * Reset the consecutive failure count for an account + * Called on successful request (matches opencode-antigravity-auth) + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account + * @returns {boolean} True if account was found and reset + */ +export function resetConsecutiveFailures(accounts, email) { + const account = accounts.find(a => a.email === email); + if (!account) return false; + account.consecutiveFailures = 0; + return true; +} + +/** + * Increment the consecutive failure count for an account WITHOUT marking as rate limited + * Used for quick retries where we want to track failures but not skip the account + * (matches opencode-antigravity-auth behavior of always incrementing on 429) + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account + * @returns {number} New consecutive failure count + */ +export function incrementConsecutiveFailures(accounts, email) { + const account = accounts.find(a => a.email === email); + if (!account) return 0; + account.consecutiveFailures = (account.consecutiveFailures || 0) + 1; + return account.consecutiveFailures; +} + +// ============================================================================ +// Cooldown Mechanism (matches opencode-antigravity-auth) +// Separate from rate limits - used for temporary backoff after failures +// ============================================================================ + +/** + * Cooldown reasons for debugging/logging + */ +export const CooldownReason = { + RATE_LIMIT: 'rate_limit', + AUTH_FAILURE: 'auth_failure', + CONSECUTIVE_FAILURES: 'consecutive_failures', + SERVER_ERROR: 'server_error' +}; + +/** + * Mark an account as cooling down for a specified duration + * Used for temporary backoff separate from rate limits + * + * @param {Array} accounts - Array of account objects + * @param {string} email - Email of the account + * @param {number} cooldownMs - Duration of cooldown in milliseconds + * @param {string} [reason] - Reason for the cooldown + * @returns {boolean} True if account was found and marked + */ +export function markAccountCoolingDown(accounts, email, cooldownMs, reason = CooldownReason.RATE_LIMIT) { + const account = accounts.find(a => a.email === email); + if (!account) return false; + + account.coolingDownUntil = Date.now() + cooldownMs; + account.cooldownReason = reason; + + logger.debug(`[AccountManager] Account ${email} cooling down for ${formatDuration(cooldownMs)} (reason: ${reason})`); + return true; +} + +/** + * Check if an account is currently cooling down + * Automatically clears expired cooldowns + * + * @param {Object} account - Account object + * @returns {boolean} True if account is cooling down + */ +export function isAccountCoolingDown(account) { + if (!account || account.coolingDownUntil === undefined) { + return false; + } + + const now = Date.now(); + if (now >= account.coolingDownUntil) { + // Cooldown expired - clear it + clearAccountCooldown(account); + return false; + } + + return true; +} + +/** + * Clear the cooldown for an account + * + * @param {Object} account - Account object + */ +export function clearAccountCooldown(account) { + if (account) { + delete account.coolingDownUntil; + delete account.cooldownReason; + } +} + +/** + * Get time remaining until cooldown expires for an account + * + * @param {Object} account - Account object + * @returns {number} Milliseconds until cooldown expires, 0 if not cooling down + */ +export function getCooldownRemaining(account) { + if (!account || account.coolingDownUntil === undefined) { + return 0; + } + + const remaining = account.coolingDownUntil - Date.now(); + return remaining > 0 ? remaining : 0; +} diff --git a/src/account-manager/storage.js b/src/account-manager/storage.js new file mode 100755 index 0000000000000000000000000000000000000000..65a3c332fa54f1d3ad07f3640726ec934ce433f4 --- /dev/null +++ b/src/account-manager/storage.js @@ -0,0 +1,165 @@ +/** + * Account Storage + * + * Handles loading and saving account configuration to disk. + */ + +import { readFile, writeFile, mkdir, access, rename } from 'fs/promises'; +import { constants as fsConstants } from 'fs'; +import { dirname } from 'path'; +import { ACCOUNT_CONFIG_PATH } from '../constants.js'; +import { getAuthStatus } from '../auth/database.js'; +import { logger } from '../utils/logger.js'; + +let writeLock = null; + +/** + * Load accounts from the config file + * + * @param {string} configPath - Path to the config file + * @returns {Promise<{accounts: Array, settings: Object, activeIndex: number}>} + */ +export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) { + try { + // Check if config file exists using async access + await access(configPath, fsConstants.F_OK); + const configData = await readFile(configPath, 'utf-8'); + const config = JSON.parse(configData); + + const accounts = (config.accounts || []).map(acc => ({ + ...acc, + lastUsed: acc.lastUsed || null, + enabled: acc.enabled !== false, // Default to true if not specified + // Reset invalid flag on startup - give accounts a fresh chance + // EXCEPT accounts with a verifyUrl — those need user intervention + isInvalid: acc.verifyUrl ? (acc.isInvalid || false) : false, + invalidReason: acc.verifyUrl ? (acc.invalidReason || null) : null, + verifyUrl: acc.verifyUrl || null, + modelRateLimits: acc.modelRateLimits || {}, + // New fields for subscription and quota tracking + subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, + quota: acc.quota || { models: {}, lastChecked: null }, + // Quota threshold settings (per-account and per-model overrides) + quotaThreshold: acc.quotaThreshold, // undefined means use global + modelQuotaThresholds: acc.modelQuotaThresholds || {} + })); + + const settings = config.settings || {}; + let activeIndex = config.activeIndex || 0; + + // Clamp activeIndex to valid range + if (activeIndex >= accounts.length) { + activeIndex = 0; + } + + logger.info(`[AccountManager] Loaded ${accounts.length} account(s) from config`); + + return { accounts, settings, activeIndex }; + } catch (error) { + if (error.code === 'ENOENT') { + // No config file - return empty + logger.info('[AccountManager] No config file found. Using Antigravity database (single account mode)'); + } else { + logger.error('[AccountManager] Failed to load config:', error.message); + } + return { accounts: [], settings: {}, activeIndex: 0 }; + } +} + +/** + * Load the default account from Antigravity's database + * + * @param {string} dbPath - Optional path to the database + * @returns {{accounts: Array, tokenCache: Map}} + */ +export function loadDefaultAccount(dbPath) { + try { + const authData = getAuthStatus(dbPath); + if (authData?.apiKey) { + const account = { + email: authData.email || 'default@antigravity', + source: 'database', + lastUsed: null, + modelRateLimits: {} + }; + + const tokenCache = new Map(); + tokenCache.set(account.email, { + token: authData.apiKey, + extractedAt: Date.now() + }); + + logger.info(`[AccountManager] Loaded default account: ${account.email}`); + + return { accounts: [account], tokenCache }; + } + } catch (error) { + logger.error('[AccountManager] Failed to load default account:', error.message); + } + + return { accounts: [], tokenCache: new Map() }; +} + +/** + * Save account configuration to disk + * + * @param {string} configPath - Path to the config file + * @param {Array} accounts - Array of account objects + * @param {Object} settings - Settings object + * @param {number} activeIndex - Current active account index + */ +export async function saveAccounts(configPath, accounts, settings, activeIndex) { + // Serialize writes to prevent concurrent corruption + const previousLock = writeLock; + let resolve; + writeLock = new Promise(r => { resolve = r; }); + + try { + if (previousLock) await previousLock; + } catch { + // Previous write failed, proceed anyway + } + + try { + const dir = dirname(configPath); + await mkdir(dir, { recursive: true }); + + const config = { + accounts: accounts.map(acc => ({ + email: acc.email, + source: acc.source, + enabled: acc.enabled !== false, + dbPath: acc.dbPath || null, + refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined, + apiKey: acc.source === 'manual' ? acc.apiKey : undefined, + projectId: acc.projectId || undefined, + addedAt: acc.addedAt || undefined, + isInvalid: acc.isInvalid || false, + invalidReason: acc.invalidReason || null, + verifyUrl: acc.verifyUrl || null, + modelRateLimits: acc.modelRateLimits || {}, + lastUsed: acc.lastUsed, + subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, + quota: acc.quota || { models: {}, lastChecked: null }, + quotaThreshold: acc.quotaThreshold, + modelQuotaThresholds: Object.keys(acc.modelQuotaThresholds || {}).length > 0 ? acc.modelQuotaThresholds : undefined + })), + settings: settings, + activeIndex: activeIndex + }; + + const json = JSON.stringify(config, null, 2); + + // Validate JSON before writing (prevent saving corrupt data) + JSON.parse(json); + + // Atomic write: write to temp file then rename + const tmpPath = configPath + '.tmp'; + await writeFile(tmpPath, json); + await rename(tmpPath, configPath); + } catch (error) { + logger.error('[AccountManager] Failed to save config:', error.message); + } finally { + resolve(); + } +} diff --git a/src/account-manager/strategies/base-strategy.js b/src/account-manager/strategies/base-strategy.js new file mode 100755 index 0000000000000000000000000000000000000000..d11bcd503753917c6a4c4f9c26ea35d659379ff8 --- /dev/null +++ b/src/account-manager/strategies/base-strategy.js @@ -0,0 +1,109 @@ +/** + * Base Strategy + * + * Abstract base class defining the interface for account selection strategies. + * All strategies must implement the selectAccount method. + */ + +import { isAccountCoolingDown } from '../rate-limits.js'; + +/** + * @typedef {Object} SelectionResult + * @property {Object|null} account - The selected account or null if none available + * @property {number} index - The index of the selected account + * @property {number} [waitMs] - Optional wait time before account becomes available + */ + +export class BaseStrategy { + /** + * Create a new BaseStrategy + * @param {Object} config - Strategy configuration + */ + constructor(config = {}) { + if (new.target === BaseStrategy) { + throw new Error('BaseStrategy is abstract and cannot be instantiated directly'); + } + this.config = config; + } + + /** + * Select an account for a request + * @param {Array} accounts - Array of account objects + * @param {string} modelId - The model ID for the request + * @param {Object} options - Additional options + * @param {number} options.currentIndex - Current account index + * @param {string} [options.sessionId] - Session ID for cache continuity + * @param {Function} [options.onSave] - Callback to save changes + * @returns {SelectionResult} The selected account and index + */ + selectAccount(accounts, modelId, options = {}) { + throw new Error('selectAccount must be implemented by subclass'); + } + + /** + * Called after a successful request + * @param {Object} account - The account that was used + * @param {string} modelId - The model ID that was used + */ + onSuccess(account, modelId) { + // Default: no-op, override in subclass if needed + } + + /** + * Called when a request is rate-limited + * @param {Object} account - The account that was rate-limited + * @param {string} modelId - The model ID that was rate-limited + */ + onRateLimit(account, modelId) { + // Default: no-op, override in subclass if needed + } + + /** + * Called when a request fails (non-rate-limit error) + * @param {Object} account - The account that failed + * @param {string} modelId - The model ID that failed + */ + onFailure(account, modelId) { + // Default: no-op, override in subclass if needed + } + + /** + * Check if an account is usable for a specific model + * @param {Object} account - Account object + * @param {string} modelId - Model ID to check + * @returns {boolean} True if account is usable + */ + isAccountUsable(account, modelId) { + if (!account || account.isInvalid) return false; + + // Skip disabled accounts + if (account.enabled === false) return false; + + // Check if account is cooling down (matches opencode-antigravity-auth) + if (isAccountCoolingDown(account)) return false; + + // Check model-specific rate limit + if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) { + const limit = account.modelRateLimits[modelId]; + if (limit.isRateLimited && limit.resetTime > Date.now()) { + return false; + } + } + + return true; + } + + /** + * Get all usable accounts for a model + * @param {Array} accounts - Array of account objects + * @param {string} modelId - Model ID to check + * @returns {Array} Array of usable accounts with their original indices + */ + getUsableAccounts(accounts, modelId) { + return accounts + .map((account, index) => ({ account, index })) + .filter(({ account }) => this.isAccountUsable(account, modelId)); + } +} + +export default BaseStrategy; diff --git a/src/account-manager/strategies/hybrid-strategy.js b/src/account-manager/strategies/hybrid-strategy.js new file mode 100755 index 0000000000000000000000000000000000000000..242777ea6f82490680ae25490504a3a05a4dc756 --- /dev/null +++ b/src/account-manager/strategies/hybrid-strategy.js @@ -0,0 +1,347 @@ +/** + * Hybrid Strategy + * + * Smart selection based on health score, token bucket, quota, and LRU freshness. + * Combines multiple signals for optimal account distribution. + * + * Scoring formula: + * score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (Quota × 3) + (LRU × 0.1) + * + * Filters accounts that are: + * - Not rate-limited + * - Not invalid or disabled + * - Health score >= minUsable + * - Has tokens available + * - Quota not critically low (< 5%) + */ + +import { BaseStrategy } from './base-strategy.js'; +import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js'; +import { logger } from '../../utils/logger.js'; +import { config } from '../../config.js'; + +// Default weights for scoring +const DEFAULT_WEIGHTS = { + health: 2, + tokens: 5, + quota: 3, + lru: 0.1 +}; + +export class HybridStrategy extends BaseStrategy { + #healthTracker; + #tokenBucketTracker; + #quotaTracker; + #weights; + + /** + * Create a new HybridStrategy + * @param {Object} config - Strategy configuration + * @param {Object} [config.healthScore] - Health tracker configuration + * @param {Object} [config.tokenBucket] - Token bucket configuration + * @param {Object} [config.quota] - Quota tracker configuration + * @param {Object} [config.weights] - Scoring weights + */ + constructor(config = {}) { + super(config); + this.#healthTracker = new HealthTracker(config.healthScore || {}); + this.#tokenBucketTracker = new TokenBucketTracker(config.tokenBucket || {}); + this.#quotaTracker = new QuotaTracker(config.quota || {}); + this.#weights = { ...DEFAULT_WEIGHTS, ...config.weights }; + } + + /** + * Select an account based on combined health, tokens, and LRU score + * + * @param {Array} accounts - Array of account objects + * @param {string} modelId - The model ID for the request + * @param {Object} options - Additional options + * @returns {SelectionResult} The selected account and index + */ + selectAccount(accounts, modelId, options = {}) { + const { onSave } = options; + + if (accounts.length === 0) { + return { account: null, index: 0, waitMs: 0 }; + } + + // Get candidates that pass all filters + const { candidates, fallbackLevel } = this.#getCandidates(accounts, modelId); + + if (candidates.length === 0) { + // Diagnose why no candidates are available and compute wait time + const { reason, waitMs } = this.#diagnoseNoCandidates(accounts, modelId); + logger.warn(`[HybridStrategy] No candidates available: ${reason}`); + return { account: null, index: 0, waitMs }; + } + + // Score and sort candidates + const scored = candidates.map(({ account, index }) => ({ + account, + index, + score: this.#calculateScore(account, modelId) + })); + + scored.sort((a, b) => b.score - a.score); + + // Select the best candidate + const best = scored[0]; + best.account.lastUsed = Date.now(); + + // Consume a token from the bucket (unless in lastResort mode where we bypassed token check) + if (fallbackLevel !== 'lastResort') { + this.#tokenBucketTracker.consume(best.account.email); + } + + if (onSave) onSave(); + + // Calculate throttle wait time based on fallback level + // This prevents overwhelming the API when all accounts are stressed + let waitMs = 0; + if (fallbackLevel === 'lastResort') { + // All accounts exhausted - add significant delay to allow rate limits to clear + waitMs = 500; + } else if (fallbackLevel === 'emergency') { + // All accounts unhealthy - add moderate delay + waitMs = 250; + } + + const position = best.index + 1; + const total = accounts.length; + const fallbackInfo = fallbackLevel !== 'normal' ? `, fallback: ${fallbackLevel}` : ''; + logger.info(`[HybridStrategy] Using account: ${best.account.email} (${position}/${total}, score: ${best.score.toFixed(1)}${fallbackInfo})`); + + return { account: best.account, index: best.index, waitMs }; + } + + /** + * Called after a successful request + */ + onSuccess(account, modelId) { + if (account && account.email) { + this.#healthTracker.recordSuccess(account.email); + } + } + + /** + * Called when a request is rate-limited + */ + onRateLimit(account, modelId) { + if (account && account.email) { + this.#healthTracker.recordRateLimit(account.email); + } + } + + /** + * Called when a request fails + */ + onFailure(account, modelId) { + if (account && account.email) { + this.#healthTracker.recordFailure(account.email); + // Refund the token since the request didn't complete + this.#tokenBucketTracker.refund(account.email); + } + } + + /** + * Get candidates that pass all filters + * @private + * @returns {{candidates: Array, fallbackLevel: string}} Candidates and fallback level used + * fallbackLevel: 'normal' | 'quota' | 'emergency' | 'lastResort' + */ + #getCandidates(accounts, modelId) { + const candidates = accounts + .map((account, index) => ({ account, index })) + .filter(({ account }) => { + // Basic usability check + if (!this.isAccountUsable(account, modelId)) { + return false; + } + + // Health score check + if (!this.#healthTracker.isUsable(account.email)) { + return false; + } + + // Token availability check + if (!this.#tokenBucketTracker.hasTokens(account.email)) { + return false; + } + + // Quota availability check (exclude critically low quota) + // Threshold priority: per-model > per-account > global > default + const effectiveThreshold = account.modelQuotaThresholds?.[modelId] + ?? account.quotaThreshold + ?? (config.globalQuotaThreshold || undefined); + if (this.#quotaTracker.isQuotaCritical(account, modelId, effectiveThreshold)) { + logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId} (threshold: ${effectiveThreshold ?? 'default'})`); + return false; + } + + return true; + }); + + if (candidates.length > 0) { + return { candidates, fallbackLevel: 'normal' }; + } + + // If no candidates after quota filter, fall back to all usable accounts + // (better to use critical quota than fail entirely) + const fallback = accounts + .map((account, index) => ({ account, index })) + .filter(({ account }) => { + if (!this.isAccountUsable(account, modelId)) return false; + if (!this.#healthTracker.isUsable(account.email)) return false; + if (!this.#tokenBucketTracker.hasTokens(account.email)) return false; + return true; + }); + if (fallback.length > 0) { + logger.warn('[HybridStrategy] All accounts have critical quota, using fallback'); + return { candidates: fallback, fallbackLevel: 'quota' }; + } + + // Emergency fallback: bypass health check when ALL accounts are unhealthy + // This prevents "Max retries exceeded" when health scores are too low + const emergency = accounts + .map((account, index) => ({ account, index })) + .filter(({ account }) => { + if (!this.isAccountUsable(account, modelId)) return false; + if (!this.#tokenBucketTracker.hasTokens(account.email)) return false; + // Skip health check - use "least bad" account + return true; + }); + if (emergency.length > 0) { + logger.warn('[HybridStrategy] EMERGENCY: All accounts unhealthy, using least bad account'); + return { candidates: emergency, fallbackLevel: 'emergency' }; + } + + // Last resort: bypass BOTH health AND token bucket checks + // Only check basic usability (not rate-limited, not disabled) + const lastResort = accounts + .map((account, index) => ({ account, index })) + .filter(({ account }) => { + // Only check if account is usable (not rate-limited, not disabled) + if (!this.isAccountUsable(account, modelId)) return false; + // Skip health and token bucket checks entirely + return true; + }); + if (lastResort.length > 0) { + logger.warn('[HybridStrategy] LAST RESORT: All accounts exhausted, using any usable account'); + return { candidates: lastResort, fallbackLevel: 'lastResort' }; + } + + return { candidates: [], fallbackLevel: 'normal' }; + } + + /** + * Calculate the combined score for an account + * @private + */ + #calculateScore(account, modelId) { + const email = account.email; + + // Health component (0-100 scaled by weight) + const health = this.#healthTracker.getScore(email); + const healthComponent = health * this.#weights.health; + + // Token component (0-100 scaled by weight) + const tokens = this.#tokenBucketTracker.getTokens(email); + const maxTokens = this.#tokenBucketTracker.getMaxTokens(); + const tokenRatio = tokens / maxTokens; + const tokenComponent = (tokenRatio * 100) * this.#weights.tokens; + + // Quota component (0-100 scaled by weight) + const quotaScore = this.#quotaTracker.getScore(account, modelId); + const quotaComponent = quotaScore * this.#weights.quota; + + // LRU component (older = higher score) + // Use time since last use in seconds, capped at 1 hour (matches opencode-antigravity-auth) + const lastUsed = account.lastUsed || 0; + const timeSinceLastUse = Math.min(Date.now() - lastUsed, 3600000); // Cap at 1 hour + const lruSeconds = timeSinceLastUse / 1000; + const lruComponent = lruSeconds * this.#weights.lru; // 0-3600 * 0.1 = 0-360 max + + return healthComponent + tokenComponent + quotaComponent + lruComponent; + } + + /** + * Get the health tracker (for testing/debugging) + * @returns {HealthTracker} The health tracker instance + */ + getHealthTracker() { + return this.#healthTracker; + } + + /** + * Get the token bucket tracker (for testing/debugging) + * @returns {TokenBucketTracker} The token bucket tracker instance + */ + getTokenBucketTracker() { + return this.#tokenBucketTracker; + } + + /** + * Get the quota tracker (for testing/debugging) + * @returns {QuotaTracker} The quota tracker instance + */ + getQuotaTracker() { + return this.#quotaTracker; + } + + /** + * Diagnose why no candidates are available and compute wait time + * @private + * @param {Array} accounts - Array of account objects + * @param {string} modelId - The model ID + * @returns {{reason: string, waitMs: number}} Diagnosis result + */ + #diagnoseNoCandidates(accounts, modelId) { + let unusableCount = 0; + let unhealthyCount = 0; + let noTokensCount = 0; + let criticalQuotaCount = 0; + const accountsWithoutTokens = []; + + for (const account of accounts) { + if (!this.isAccountUsable(account, modelId)) { + unusableCount++; + continue; + } + if (!this.#healthTracker.isUsable(account.email)) { + unhealthyCount++; + continue; + } + if (!this.#tokenBucketTracker.hasTokens(account.email)) { + noTokensCount++; + accountsWithoutTokens.push(account.email); + continue; + } + const diagThreshold = account.modelQuotaThresholds?.[modelId] + ?? account.quotaThreshold + ?? (config.globalQuotaThreshold || undefined); + if (this.#quotaTracker.isQuotaCritical(account, modelId, diagThreshold)) { + criticalQuotaCount++; + continue; + } + } + + // If all accounts are blocked by token bucket, calculate wait time + if (noTokensCount > 0 && unusableCount === 0 && unhealthyCount === 0) { + const waitMs = this.#tokenBucketTracker.getMinTimeUntilToken(accountsWithoutTokens); + const reason = `all ${noTokensCount} account(s) exhausted token bucket, waiting for refill`; + return { reason, waitMs }; + } + + // Build reason string + const parts = []; + if (unusableCount > 0) parts.push(`${unusableCount} unusable/disabled`); + if (unhealthyCount > 0) parts.push(`${unhealthyCount} unhealthy`); + if (noTokensCount > 0) parts.push(`${noTokensCount} no tokens`); + if (criticalQuotaCount > 0) parts.push(`${criticalQuotaCount} critical quota`); + + const reason = parts.length > 0 ? parts.join(', ') : 'unknown'; + return { reason, waitMs: 0 }; + } +} + +export default HybridStrategy; diff --git a/src/account-manager/strategies/index.js b/src/account-manager/strategies/index.js new file mode 100755 index 0000000000000000000000000000000000000000..70ae00cc42ee27d14bc325f26b9f1ff83c61cc2a --- /dev/null +++ b/src/account-manager/strategies/index.js @@ -0,0 +1,79 @@ +/** + * Strategy Factory + * + * Creates and exports account selection strategy instances. + */ + +import { StickyStrategy } from './sticky-strategy.js'; +import { RoundRobinStrategy } from './round-robin-strategy.js'; +import { HybridStrategy } from './hybrid-strategy.js'; +import { logger } from '../../utils/logger.js'; +import { + SELECTION_STRATEGIES, + DEFAULT_SELECTION_STRATEGY, + STRATEGY_LABELS +} from '../../constants.js'; + +// Re-export strategy constants for convenience +export const STRATEGY_NAMES = SELECTION_STRATEGIES; +export const DEFAULT_STRATEGY = DEFAULT_SELECTION_STRATEGY; + +/** + * Create a strategy instance + * @param {string} strategyName - Name of the strategy ('sticky', 'round-robin', 'hybrid') + * @param {Object} config - Strategy configuration + * @returns {BaseStrategy} The strategy instance + */ +export function createStrategy(strategyName, config = {}) { + const name = (strategyName || DEFAULT_STRATEGY).toLowerCase(); + + switch (name) { + case 'sticky': + logger.debug('[Strategy] Creating StickyStrategy'); + return new StickyStrategy(config); + + case 'round-robin': + case 'roundrobin': + logger.debug('[Strategy] Creating RoundRobinStrategy'); + return new RoundRobinStrategy(config); + + case 'hybrid': + logger.debug('[Strategy] Creating HybridStrategy'); + return new HybridStrategy(config); + + default: + logger.warn(`[Strategy] Unknown strategy "${strategyName}", falling back to ${DEFAULT_STRATEGY}`); + return new HybridStrategy(config); + } +} + +/** + * Check if a strategy name is valid + * @param {string} name - Strategy name to check + * @returns {boolean} True if valid + */ +export function isValidStrategy(name) { + if (!name) return false; + const lower = name.toLowerCase(); + return STRATEGY_NAMES.includes(lower) || lower === 'roundrobin'; +} + +/** + * Get the display label for a strategy + * @param {string} name - Strategy name + * @returns {string} Display label + */ +export function getStrategyLabel(name) { + const lower = (name || DEFAULT_STRATEGY).toLowerCase(); + if (lower === 'roundrobin') return STRATEGY_LABELS['round-robin']; + return STRATEGY_LABELS[lower] || STRATEGY_LABELS[DEFAULT_STRATEGY]; +} + +// Re-export strategies for direct use +export { StickyStrategy } from './sticky-strategy.js'; +export { RoundRobinStrategy } from './round-robin-strategy.js'; +export { HybridStrategy } from './hybrid-strategy.js'; +export { BaseStrategy } from './base-strategy.js'; + +// Re-export trackers +export { HealthTracker, TokenBucketTracker } from './trackers/index.js'; diff --git a/src/account-manager/strategies/round-robin-strategy.js b/src/account-manager/strategies/round-robin-strategy.js new file mode 100755 index 0000000000000000000000000000000000000000..1b79bd60461692a1ef547d0a58ebe81d2bc20051 --- /dev/null +++ b/src/account-manager/strategies/round-robin-strategy.js @@ -0,0 +1,76 @@ +/** + * Round-Robin Strategy + * + * Rotates to the next account on every request for maximum throughput. + * Does not maintain cache continuity but maximizes concurrent requests. + */ + +import { BaseStrategy } from './base-strategy.js'; +import { logger } from '../../utils/logger.js'; + +export class RoundRobinStrategy extends BaseStrategy { + #cursor = 0; // Tracks current position in rotation + + /** + * Create a new RoundRobinStrategy + * @param {Object} config - Strategy configuration + */ + constructor(config = {}) { + super(config); + } + + /** + * Select the next available account in rotation + * + * @param {Array} accounts - Array of account objects + * @param {string} modelId - The model ID for the request + * @param {Object} options - Additional options + * @returns {SelectionResult} The selected account and index + */ + selectAccount(accounts, modelId, options = {}) { + const { onSave } = options; + + if (accounts.length === 0) { + return { account: null, index: 0, waitMs: 0 }; + } + + // Clamp cursor to valid range + if (this.#cursor >= accounts.length) { + this.#cursor = 0; + } + + // Start from the next position after the cursor + const startIndex = (this.#cursor + 1) % accounts.length; + + // Try each account starting from startIndex + for (let i = 0; i < accounts.length; i++) { + const idx = (startIndex + i) % accounts.length; + const account = accounts[idx]; + + if (this.isAccountUsable(account, modelId)) { + account.lastUsed = Date.now(); + this.#cursor = idx; + + if (onSave) onSave(); + + const position = idx + 1; + const total = accounts.length; + logger.info(`[RoundRobinStrategy] Using account: ${account.email} (${position}/${total})`); + + return { account, index: idx, waitMs: 0 }; + } + } + + // No usable accounts found + return { account: null, index: this.#cursor, waitMs: 0 }; + } + + /** + * Reset the cursor position + */ + resetCursor() { + this.#cursor = 0; + } +} + +export default RoundRobinStrategy; diff --git a/src/account-manager/strategies/sticky-strategy.js b/src/account-manager/strategies/sticky-strategy.js new file mode 100755 index 0000000000000000000000000000000000000000..988f3709cc7568a4e90c0944de8a5656e26abcfd --- /dev/null +++ b/src/account-manager/strategies/sticky-strategy.js @@ -0,0 +1,138 @@ +/** + * Sticky Strategy + * + * Keeps using the same account until it becomes unavailable (rate-limited or invalid). + * Best for prompt caching as it maintains cache continuity across requests. + */ + +import { BaseStrategy } from './base-strategy.js'; +import { logger } from '../../utils/logger.js'; +import { formatDuration } from '../../utils/helpers.js'; +import { MAX_WAIT_BEFORE_ERROR_MS } from '../../constants.js'; + +export class StickyStrategy extends BaseStrategy { + /** + * Create a new StickyStrategy + * @param {Object} config - Strategy configuration + */ + constructor(config = {}) { + super(config); + } + + /** + * Select an account with sticky preference + * Prefers the current account for cache continuity, only switches when: + * - Current account is rate-limited for > 2 minutes + * - Current account is invalid + * - Current account is disabled + * + * @param {Array} accounts - Array of account objects + * @param {string} modelId - The model ID for the request + * @param {Object} options - Additional options + * @returns {SelectionResult} The selected account and index + */ + selectAccount(accounts, modelId, options = {}) { + const { currentIndex = 0, onSave } = options; + + if (accounts.length === 0) { + return { account: null, index: currentIndex, waitMs: 0 }; + } + + // Clamp index to valid range + let index = currentIndex >= accounts.length ? 0 : currentIndex; + const currentAccount = accounts[index]; + + // Check if current account is usable + if (this.isAccountUsable(currentAccount, modelId)) { + currentAccount.lastUsed = Date.now(); + if (onSave) onSave(); + return { account: currentAccount, index, waitMs: 0 }; + } + + // Current account is not usable - check if others are available + const usableAccounts = this.getUsableAccounts(accounts, modelId); + + if (usableAccounts.length > 0) { + // Found a free account - switch immediately + const { account: nextAccount, index: nextIndex } = this.#pickNext( + accounts, + index, + modelId, + onSave + ); + if (nextAccount) { + logger.info(`[StickyStrategy] Switched to new account (failover): ${nextAccount.email}`); + return { account: nextAccount, index: nextIndex, waitMs: 0 }; + } + } + + // No other accounts available - check if we should wait for current + const waitInfo = this.#shouldWaitForAccount(currentAccount, modelId); + if (waitInfo.shouldWait) { + logger.info(`[StickyStrategy] Waiting ${formatDuration(waitInfo.waitMs)} for sticky account: ${currentAccount.email}`); + return { account: null, index, waitMs: waitInfo.waitMs }; + } + + // Current account unavailable for too long, try to find any other + const { account: nextAccount, index: nextIndex } = this.#pickNext( + accounts, + index, + modelId, + onSave + ); + + return { account: nextAccount, index: nextIndex, waitMs: 0 }; + } + + /** + * Pick the next available account starting from after the current index + * @private + */ + #pickNext(accounts, currentIndex, modelId, onSave) { + for (let i = 1; i <= accounts.length; i++) { + const idx = (currentIndex + i) % accounts.length; + const account = accounts[idx]; + + if (this.isAccountUsable(account, modelId)) { + account.lastUsed = Date.now(); + if (onSave) onSave(); + + const position = idx + 1; + const total = accounts.length; + logger.info(`[StickyStrategy] Using account: ${account.email} (${position}/${total})`); + + return { account, index: idx }; + } + } + + return { account: null, index: currentIndex }; + } + + /** + * Check if we should wait for an account's rate limit to reset + * @private + */ + #shouldWaitForAccount(account, modelId) { + if (!account || account.isInvalid || account.enabled === false) { + return { shouldWait: false, waitMs: 0 }; + } + + let waitMs = 0; + + if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) { + const limit = account.modelRateLimits[modelId]; + if (limit.isRateLimited && limit.resetTime) { + waitMs = limit.resetTime - Date.now(); + } + } + + // Wait if within threshold + if (waitMs > 0 && waitMs <= MAX_WAIT_BEFORE_ERROR_MS) { + return { shouldWait: true, waitMs }; + } + + return { shouldWait: false, waitMs: 0 }; + } +} + +export default StickyStrategy; diff --git a/src/account-manager/strategies/trackers/health-tracker.js b/src/account-manager/strategies/trackers/health-tracker.js new file mode 100755 index 0000000000000000000000000000000000000000..15d2da2829ff392d46718f7bf11e099bce895b1e --- /dev/null +++ b/src/account-manager/strategies/trackers/health-tracker.js @@ -0,0 +1,162 @@ +/** + * Health Tracker + * + * Tracks per-account health scores to prioritize healthy accounts. + * Scores increase on success and decrease on failures/rate limits. + * Passive recovery over time helps accounts recover from temporary issues. + */ + +// Default configuration (matches opencode-antigravity-auth) +const DEFAULT_CONFIG = { + initial: 70, // Starting score for new accounts + successReward: 1, // Points on successful request + rateLimitPenalty: -10, // Points on rate limit + failurePenalty: -20, // Points on other failures + recoveryPerHour: 10, // Passive recovery rate (increased from 2 for faster recovery) + minUsable: 50, // Minimum score to be selected + maxScore: 100 // Maximum score cap +}; + +export class HealthTracker { + #scores = new Map(); // email -> { score, lastUpdated, consecutiveFailures } + #config; + + /** + * Create a new HealthTracker + * @param {Object} config - Health score configuration + */ + constructor(config = {}) { + this.#config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Get the health score for an account + * @param {string} email - Account email + * @returns {number} Current health score (with passive recovery applied) + */ + getScore(email) { + const record = this.#scores.get(email); + if (!record) { + return this.#config.initial; + } + + // Apply passive recovery based on time elapsed + const now = Date.now(); + const hoursElapsed = (now - record.lastUpdated) / (1000 * 60 * 60); + const recovery = hoursElapsed * this.#config.recoveryPerHour; + const recoveredScore = Math.min( + this.#config.maxScore, + record.score + recovery + ); + + return recoveredScore; + } + + /** + * Record a successful request for an account + * @param {string} email - Account email + */ + recordSuccess(email) { + const currentScore = this.getScore(email); + const newScore = Math.min( + this.#config.maxScore, + currentScore + this.#config.successReward + ); + this.#scores.set(email, { + score: newScore, + lastUpdated: Date.now(), + consecutiveFailures: 0 // Reset on success + }); + } + + /** + * Record a rate limit for an account + * @param {string} email - Account email + */ + recordRateLimit(email) { + const record = this.#scores.get(email); + const currentScore = this.getScore(email); + const newScore = Math.max( + 0, + currentScore + this.#config.rateLimitPenalty + ); + this.#scores.set(email, { + score: newScore, + lastUpdated: Date.now(), + consecutiveFailures: (record?.consecutiveFailures ?? 0) + 1 + }); + } + + /** + * Record a failure for an account + * @param {string} email - Account email + */ + recordFailure(email) { + const record = this.#scores.get(email); + const currentScore = this.getScore(email); + const newScore = Math.max( + 0, + currentScore + this.#config.failurePenalty + ); + this.#scores.set(email, { + score: newScore, + lastUpdated: Date.now(), + consecutiveFailures: (record?.consecutiveFailures ?? 0) + 1 + }); + } + + /** + * Check if an account is usable based on health score + * @param {string} email - Account email + * @returns {boolean} True if account health score is above minimum threshold + */ + isUsable(email) { + return this.getScore(email) >= this.#config.minUsable; + } + + /** + * Get the minimum usable score threshold + * @returns {number} Minimum score for an account to be usable + */ + getMinUsable() { + return this.#config.minUsable; + } + + /** + * Get the maximum score cap + * @returns {number} Maximum health score + */ + getMaxScore() { + return this.#config.maxScore; + } + + /** + * Reset the score for an account (e.g., after re-authentication) + * @param {string} email - Account email + */ + reset(email) { + this.#scores.set(email, { + score: this.#config.initial, + lastUpdated: Date.now(), + consecutiveFailures: 0 + }); + } + + /** + * Get the consecutive failure count for an account + * @param {string} email - Account email + * @returns {number} Number of consecutive failures + */ + getConsecutiveFailures(email) { + return this.#scores.get(email)?.consecutiveFailures ?? 0; + } + + /** + * Clear all tracked scores + */ + clear() { + this.#scores.clear(); + } +} + +export default HealthTracker; diff --git a/src/account-manager/strategies/trackers/index.js b/src/account-manager/strategies/trackers/index.js new file mode 100755 index 0000000000000000000000000000000000000000..f2f9839734e2cf92f594985b59a66f7bc4c1fdfa --- /dev/null +++ b/src/account-manager/strategies/trackers/index.js @@ -0,0 +1,9 @@ +/** + * Trackers Index + * + * Exports all tracker classes for account selection strategies. + */ + +export { HealthTracker } from './health-tracker.js'; +export { TokenBucketTracker } from './token-bucket-tracker.js'; +export { QuotaTracker } from './quota-tracker.js'; diff --git a/src/account-manager/strategies/trackers/quota-tracker.js b/src/account-manager/strategies/trackers/quota-tracker.js new file mode 100755 index 0000000000000000000000000000000000000000..ba1ec5bd4cae0d3d54890e2d73e2b57a371e1aee --- /dev/null +++ b/src/account-manager/strategies/trackers/quota-tracker.js @@ -0,0 +1,124 @@ +/** + * Quota Tracker + * + * Tracks per-account quota levels to prioritize accounts with available quota. + * Uses quota data from account.quota.models[modelId].remainingFraction. + * Accounts below critical threshold are excluded from selection. + */ + +// Default configuration +const DEFAULT_CONFIG = { + lowThreshold: 0.10, // 10% - reduce score + criticalThreshold: 0.05, // 5% - exclude from candidates + staleMs: 300000, // 5 min - max age of quota data to trust + unknownScore: 50 // Score for accounts with unknown quota +}; + +export class QuotaTracker { + #config; + + /** + * Create a new QuotaTracker + * @param {Object} config - Quota tracker configuration + */ + constructor(config = {}) { + this.#config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Get the quota fraction for an account and model + * @param {Object} account - Account object + * @param {string} modelId - Model ID to check + * @returns {number|null} Remaining fraction (0-1) or null if unknown + */ + getQuotaFraction(account, modelId) { + if (!account?.quota?.models?.[modelId]) return null; + const fraction = account.quota.models[modelId].remainingFraction; + return typeof fraction === 'number' ? fraction : null; + } + + /** + * Check if quota data is fresh enough to be trusted + * @param {Object} account - Account object + * @returns {boolean} True if quota data is fresh + */ + isQuotaFresh(account) { + if (!account?.quota?.lastChecked) return false; + return (Date.now() - account.quota.lastChecked) < this.#config.staleMs; + } + + /** + * Check if an account has critically low quota for a model + * @param {Object} account - Account object + * @param {string} modelId - Model ID to check + * @param {number} [thresholdOverride] - Optional threshold to use instead of default criticalThreshold + * @returns {boolean} True if quota is at or below critical threshold + */ + isQuotaCritical(account, modelId, thresholdOverride) { + const fraction = this.getQuotaFraction(account, modelId); + // Unknown quota = not critical (assume OK) + if (fraction === null) return false; + // Only apply critical check if data is fresh + if (!this.isQuotaFresh(account)) return false; + const threshold = (typeof thresholdOverride === 'number' && thresholdOverride > 0) + ? thresholdOverride + : this.#config.criticalThreshold; + return fraction <= threshold; + } + + /** + * Check if an account has low (but not critical) quota for a model + * @param {Object} account - Account object + * @param {string} modelId - Model ID to check + * @returns {boolean} True if quota is below low threshold but above critical + */ + isQuotaLow(account, modelId) { + const fraction = this.getQuotaFraction(account, modelId); + if (fraction === null) return false; + return fraction <= this.#config.lowThreshold && fraction > this.#config.criticalThreshold; + } + + /** + * Get a score (0-100) for an account based on quota + * Higher score = more quota available + * @param {Object} account - Account object + * @param {string} modelId - Model ID to check + * @returns {number} Score from 0-100 + */ + getScore(account, modelId) { + const fraction = this.getQuotaFraction(account, modelId); + + // Unknown quota = middle score + if (fraction === null) { + return this.#config.unknownScore; + } + + // Convert fraction (0-1) to score (0-100) + let score = fraction * 100; + + // Apply small penalty for stale data (reduce confidence) + if (!this.isQuotaFresh(account)) { + score *= 0.9; // 10% penalty for stale data + } + + return score; + } + + /** + * Get the critical threshold + * @returns {number} Critical threshold (0-1) + */ + getCriticalThreshold() { + return this.#config.criticalThreshold; + } + + /** + * Get the low threshold + * @returns {number} Low threshold (0-1) + */ + getLowThreshold() { + return this.#config.lowThreshold; + } +} + +export default QuotaTracker; diff --git a/src/account-manager/strategies/trackers/token-bucket-tracker.js b/src/account-manager/strategies/trackers/token-bucket-tracker.js new file mode 100755 index 0000000000000000000000000000000000000000..ea7fcd6e28a7e81f1eb64bdccb71dc903b104c7d --- /dev/null +++ b/src/account-manager/strategies/trackers/token-bucket-tracker.js @@ -0,0 +1,155 @@ +/** + * Token Bucket Tracker + * + * Client-side rate limiting using the token bucket algorithm. + * Each account has a bucket of tokens that regenerate over time. + * Requests consume tokens; accounts without tokens are deprioritized. + */ + +// Default configuration (matches opencode-antigravity-auth) +const DEFAULT_CONFIG = { + maxTokens: 50, // Maximum token capacity + tokensPerMinute: 6, // Regeneration rate + initialTokens: 50 // Starting tokens +}; + +export class TokenBucketTracker { + #buckets = new Map(); // email -> { tokens, lastUpdated } + #config; + + /** + * Create a new TokenBucketTracker + * @param {Object} config - Token bucket configuration + */ + constructor(config = {}) { + this.#config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Get the current token count for an account + * @param {string} email - Account email + * @returns {number} Current token count (with regeneration applied) + */ + getTokens(email) { + const bucket = this.#buckets.get(email); + if (!bucket) { + return this.#config.initialTokens; + } + + // Apply token regeneration based on time elapsed + const now = Date.now(); + const minutesElapsed = (now - bucket.lastUpdated) / (1000 * 60); + const regenerated = minutesElapsed * this.#config.tokensPerMinute; + const currentTokens = Math.min( + this.#config.maxTokens, + bucket.tokens + regenerated + ); + + return currentTokens; + } + + /** + * Check if an account has tokens available + * @param {string} email - Account email + * @returns {boolean} True if account has at least 1 token + */ + hasTokens(email) { + return this.getTokens(email) >= 1; + } + + /** + * Consume a token from an account's bucket + * @param {string} email - Account email + * @returns {boolean} True if token was consumed, false if no tokens available + */ + consume(email) { + const currentTokens = this.getTokens(email); + if (currentTokens < 1) { + return false; + } + + this.#buckets.set(email, { + tokens: currentTokens - 1, + lastUpdated: Date.now() + }); + return true; + } + + /** + * Refund a token to an account's bucket (e.g., on request failure before processing) + * @param {string} email - Account email + */ + refund(email) { + const currentTokens = this.getTokens(email); + const newTokens = Math.min( + this.#config.maxTokens, + currentTokens + 1 + ); + this.#buckets.set(email, { + tokens: newTokens, + lastUpdated: Date.now() + }); + } + + /** + * Get the maximum token capacity + * @returns {number} Maximum tokens per bucket + */ + getMaxTokens() { + return this.#config.maxTokens; + } + + /** + * Reset the bucket for an account + * @param {string} email - Account email + */ + reset(email) { + this.#buckets.set(email, { + tokens: this.#config.initialTokens, + lastUpdated: Date.now() + }); + } + + /** + * Clear all tracked buckets + */ + clear() { + this.#buckets.clear(); + } + + /** + * Get time in milliseconds until next token is available for an account + * @param {string} email - Account email + * @returns {number} Milliseconds until next token, 0 if tokens available now + */ + getTimeUntilNextToken(email) { + const currentTokens = this.getTokens(email); + if (currentTokens >= 1) { + return 0; + } + + // Calculate time to regenerate 1 token + const tokensNeeded = 1 - currentTokens; + const minutesNeeded = tokensNeeded / this.#config.tokensPerMinute; + return Math.ceil(minutesNeeded * 60 * 1000); + } + + /** + * Get the minimum time until any account in the list has a token + * @param {Array} emails - List of account emails + * @returns {number} Minimum milliseconds until any account has a token + */ + getMinTimeUntilToken(emails) { + if (emails.length === 0) return 0; + + let minWait = Infinity; + for (const email of emails) { + const wait = this.getTimeUntilNextToken(email); + if (wait === 0) return 0; + minWait = Math.min(minWait, wait); + } + return minWait === Infinity ? 0 : minWait; + } +} + +export default TokenBucketTracker; diff --git a/src/auth/database.js b/src/auth/database.js new file mode 100755 index 0000000000000000000000000000000000000000..a753667addfff863e1014b29e8d4698821b78471 --- /dev/null +++ b/src/auth/database.js @@ -0,0 +1,169 @@ +/** + * SQLite Database Access Module + * Provides cross-platform database operations for Antigravity state. + * + * Uses better-sqlite3 for: + * - Windows compatibility (no CLI dependency) + * - Native performance + * - Synchronous API (simple error handling) + * + * Includes auto-rebuild capability for handling Node.js version updates + * that cause native module incompatibility. + */ + +import { createRequire } from 'module'; +import { ANTIGRAVITY_DB_PATH } from '../constants.js'; +import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js'; +import { logger } from '../utils/logger.js'; +import { NativeModuleError } from '../errors.js'; + +const require = createRequire(import.meta.url); + +// Lazy-loaded Database constructor +let Database = null; +let moduleLoadError = null; + +/** + * Load the better-sqlite3 module with auto-rebuild on version mismatch + * Uses synchronous require to maintain API compatibility + * @returns {Function} The Database constructor + * @throws {Error} If module cannot be loaded even after rebuild + */ +function loadDatabaseModule() { + // Return cached module if already loaded + if (Database) return Database; + + // Re-throw cached error if previous load failed permanently + if (moduleLoadError) throw moduleLoadError; + + try { + Database = require('better-sqlite3'); + return Database; + } catch (error) { + if (isModuleVersionError(error)) { + logger.warn('[Database] Native module version mismatch detected'); + + if (attemptAutoRebuild(error)) { + // Clear require cache and retry + try { + const resolvedPath = require.resolve('better-sqlite3'); + // Clear the module and all its dependencies from cache + clearRequireCache(resolvedPath, require.cache); + + Database = require('better-sqlite3'); + logger.success('[Database] Module reloaded successfully after rebuild'); + return Database; + } catch (retryError) { + // Rebuild succeeded but reload failed - user needs to restart + moduleLoadError = new NativeModuleError( + 'Native module rebuild completed. Please restart the server to apply the fix.', + true, // rebuildSucceeded + true // restartRequired + ); + logger.info('[Database] Rebuild succeeded - server restart required'); + throw moduleLoadError; + } + } else { + moduleLoadError = new NativeModuleError( + 'Failed to auto-rebuild native module. Please run manually:\n' + + ' npm rebuild better-sqlite3\n' + + 'Or if using npx, find the package location in the error and run:\n' + + ' cd /path/to/better-sqlite3 && npm rebuild', + false, // rebuildSucceeded + false // restartRequired + ); + throw moduleLoadError; + } + } + + // Non-version-mismatch error, just throw it + throw error; + } +} + +/** + * Query Antigravity database for authentication status + * @param {string} [dbPath] - Optional custom database path + * @returns {Object} Parsed auth data with apiKey, email, name, etc. + * @throws {Error} If database doesn't exist, query fails, or no auth status found + */ +export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) { + const Db = loadDatabaseModule(); + let db; + try { + // Open database in read-only mode + db = new Db(dbPath, { + readonly: true, + fileMustExist: true + }); + + // Prepare and execute query + const stmt = db.prepare( + "SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'" + ); + const row = stmt.get(); + + if (!row || !row.value) { + throw new Error('No auth status found in database'); + } + + // Parse JSON value + const authData = JSON.parse(row.value); + + if (!authData.apiKey) { + throw new Error('Auth data missing apiKey field'); + } + + return authData; + } catch (error) { + // Enhance error messages for common issues + if (error.code === 'SQLITE_CANTOPEN') { + throw new Error( + `Database not found at ${dbPath}. ` + + 'Make sure Antigravity is installed and you are logged in.' + ); + } + // Re-throw with context if not already our error + if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) { + throw error; + } + // Re-throw native module errors from loadDatabaseModule without wrapping + if (error instanceof NativeModuleError) { + throw error; + } + throw new Error(`Failed to read Antigravity database: ${error.message}`); + } finally { + // Always close database connection + if (db) { + db.close(); + } + } +} + +/** + * Check if database exists and is accessible + * @param {string} [dbPath] - Optional custom database path + * @returns {boolean} True if database exists and can be opened + */ +export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) { + let db; + try { + const Db = loadDatabaseModule(); + db = new Db(dbPath, { + readonly: true, + fileMustExist: true + }); + return true; + } catch { + return false; + } finally { + if (db) { + db.close(); + } + } +} + +export default { + getAuthStatus, + isDatabaseAccessible +}; diff --git a/src/auth/oauth.js b/src/auth/oauth.js new file mode 100755 index 0000000000000000000000000000000000000000..bf6ae4cf640044d90012fc223e0358e47c4b04fc --- /dev/null +++ b/src/auth/oauth.js @@ -0,0 +1,548 @@ +/** + * Google OAuth with PKCE for Antigravity + * + * Implements the same OAuth flow as opencode-antigravity-auth + * to obtain refresh tokens for multiple Google accounts. + * Uses a local callback server to automatically capture the auth code. + */ + +import crypto from 'crypto'; +import http from 'http'; +import { + ANTIGRAVITY_ENDPOINT_FALLBACKS, + LOAD_CODE_ASSIST_HEADERS, + CLIENT_METADATA, + OAUTH_CONFIG, + OAUTH_REDIRECT_URI +} from '../constants.js'; +import { logger } from '../utils/logger.js'; +import { throttledFetch } from '../utils/helpers.js'; +import { onboardUser, getDefaultTierId } from '../account-manager/onboarding.js'; + +/** + * Parse refresh token parts (aligned with opencode-antigravity-auth) + * Format: refreshToken|projectId|managedProjectId + * + * @param {string} refresh - Composite refresh token string + * @returns {{refreshToken: string, projectId: string|undefined, managedProjectId: string|undefined}} + */ +export function parseRefreshParts(refresh) { + const [refreshToken = '', projectId = '', managedProjectId = ''] = (refresh ?? '').split('|'); + return { + refreshToken, + projectId: projectId || undefined, + managedProjectId: managedProjectId || undefined, + }; +} + +/** + * Format refresh token parts back into composite string + * + * @param {{refreshToken: string, projectId?: string|undefined, managedProjectId?: string|undefined}} parts + * @returns {string} Composite refresh token + */ +export function formatRefreshParts(parts) { + const projectSegment = parts.projectId ?? ''; + const base = `${parts.refreshToken}|${projectSegment}`; + return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base; +} + +/** + * Generate PKCE code verifier and challenge + */ +function generatePKCE() { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto + .createHash('sha256') + .update(verifier) + .digest('base64url'); + return { verifier, challenge }; +} + +/** + * Generate authorization URL for Google OAuth + * Returns the URL and the PKCE verifier (needed for token exchange) + * + * @param {string} [customRedirectUri] - Optional custom redirect URI (e.g. for WebUI) + * @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data + */ +export function getAuthorizationUrl(customRedirectUri = null) { + const { verifier, challenge } = generatePKCE(); + const state = crypto.randomBytes(16).toString('hex'); + + const params = new URLSearchParams({ + client_id: OAUTH_CONFIG.clientId, + redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI, + response_type: 'code', + scope: OAUTH_CONFIG.scopes.join(' '), + access_type: 'offline', + prompt: 'consent', + code_challenge: challenge, + code_challenge_method: 'S256', + state: state + }); + + return { + url: `${OAUTH_CONFIG.authUrl}?${params.toString()}`, + verifier, + state + }; +} + +/** + * Extract authorization code and state from user input. + * User can paste either: + * - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx + * - Just the code parameter: 4/0xxx... + * + * @param {string} input - User input (URL or code) + * @returns {{code: string, state: string|null}} Extracted code and optional state + */ +export function extractCodeFromInput(input) { + if (!input || typeof input !== 'string') { + throw new Error('No input provided'); + } + + const trimmed = input.trim(); + + // Check if it looks like a URL + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + try { + const url = new URL(trimmed); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + throw new Error(`OAuth error: ${error}`); + } + + if (!code) { + throw new Error('No authorization code found in URL'); + } + + return { code, state }; + } catch (e) { + if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) { + throw e; + } + throw new Error('Invalid URL format'); + } + } + + // Assume it's a raw code + // Google auth codes typically start with "4/" and are long + if (trimmed.length < 10) { + throw new Error('Input is too short to be a valid authorization code'); + } + + return { code: trimmed, state: null }; +} + +/** + * Attempt to bind server to a specific port + * @param {http.Server} server - HTTP server instance + * @param {number} port - Port to bind to + * @param {string} host - Host to bind to + * @returns {Promise} Resolves with port on success, rejects on error + */ +function tryBindPort(server, port, host = '0.0.0.0') { + return new Promise((resolve, reject) => { + const onError = (err) => { + server.removeListener('listening', onSuccess); + reject(err); + }; + const onSuccess = () => { + server.removeListener('error', onError); + resolve(port); + }; + server.once('error', onError); + server.once('listening', onSuccess); + server.listen(port, host); + }); +} + +/** + * Start a local server to receive the OAuth callback + * Implements automatic port fallback for Windows compatibility (issue #176) + * Returns an object with a promise and an abort function + * + * @param {string} expectedState - Expected state parameter for CSRF protection + * @param {number} timeoutMs - Timeout in milliseconds (default 120000) + * @returns {{promise: Promise, abort: Function, getPort: Function}} Object with promise, abort, and getPort functions + */ +export function startCallbackServer(expectedState, timeoutMs = 120000) { + let server = null; + let timeoutId = null; + let isAborted = false; + let actualPort = OAUTH_CONFIG.callbackPort; + const host = process.env.HOST || '0.0.0.0'; + + const promise = new Promise(async (resolve, reject) => { + // Build list of ports to try: primary + fallbacks + const portsToTry = [OAUTH_CONFIG.callbackPort, ...(OAUTH_CONFIG.callbackFallbackPorts || [])]; + const errors = []; + + server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${host === '0.0.0.0' ? 'localhost' : host}:${actualPort}`); + + if (url.pathname !== '/oauth-callback') { + res.writeHead(404); + res.end('Not found'); + return; + } + + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + if (error) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + Authentication Failed + +

❌ Authentication Failed

+

Error: ${error}

+

You can close this window.

+ + + `); + server.close(); + reject(new Error(`OAuth error: ${error}`)); + return; + } + + if (state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + Authentication Failed + +

❌ Authentication Failed

+

State mismatch - possible CSRF attack.

+

You can close this window.

+ + + `); + server.close(); + reject(new Error('State mismatch')); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + Authentication Failed + +

❌ Authentication Failed

+

No authorization code received.

+

You can close this window.

+ + + `); + server.close(); + reject(new Error('No authorization code')); + return; + } + + // Success! + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + Authentication Successful + +

✅ Authentication Successful!

+

You can close this window and return to the terminal.

+ + + + `); + + server.close(); + resolve(code); + }); + + // Try ports with fallback logic (issue #176 - Windows EACCES fix) + let boundSuccessfully = false; + for (const port of portsToTry) { + try { + await tryBindPort(server, port, host); + actualPort = port; + boundSuccessfully = true; + + if (port !== OAUTH_CONFIG.callbackPort) { + logger.warn(`[OAuth] Primary port ${OAUTH_CONFIG.callbackPort} unavailable, using fallback port ${port}`); + } else { + logger.info(`[OAuth] Callback server listening on ${host}:${port}`); + } + break; + } catch (err) { + const errMsg = err.code === 'EACCES' + ? `Permission denied on port ${port}` + : err.code === 'EADDRINUSE' + ? `Port ${port} already in use` + : `Failed to bind port ${port}: ${err.message}`; + errors.push(errMsg); + logger.warn(`[OAuth] ${errMsg}`); + } + } + + if (!boundSuccessfully) { + // All ports failed - provide helpful error message + const isWindows = process.platform === 'win32'; + let errorMsg = `Failed to start OAuth callback server.\nTried ports: ${portsToTry.join(', ')}\n\nErrors:\n${errors.join('\n')}`; + + if (isWindows) { + errorMsg += `\n +================== WINDOWS TROUBLESHOOTING ================== +The default port range may be reserved by Hyper-V/WSL2/Docker. + +Option 1: Use a custom port + Set OAUTH_CALLBACK_PORT=3456 in your environment or .env file + +Option 2: Reset Windows NAT (run as Administrator) + net stop winnat && net start winnat + +Option 3: Check reserved port ranges + netsh interface ipv4 show excludedportrange protocol=tcp + +Option 4: Exclude port from reservation (run as Administrator) + netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1 +==============================================================`; + } else { + errorMsg += `\n\nTry setting a custom port: OAUTH_CALLBACK_PORT=3456`; + } + + reject(new Error(errorMsg)); + return; + } + + // Timeout after specified duration + timeoutId = setTimeout(() => { + if (!isAborted) { + server.close(); + reject(new Error('OAuth callback timeout - no response received')); + } + }, timeoutMs); + }); + + // Abort function to clean up server when manual completion happens + const abort = () => { + if (isAborted) return; + isAborted = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + if (server) { + server.close(); + logger.info('[OAuth] Callback server aborted (manual completion)'); + } + }; + + // Get actual port (useful when fallback is used) + const getPort = () => actualPort; + + return { promise, abort, getPort }; +} + +/** + * Exchange authorization code for tokens + * + * @param {string} code - Authorization code from OAuth callback + * @param {string} verifier - PKCE code verifier + * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>} OAuth tokens + */ +export async function exchangeCode(code, verifier) { + const response = await throttledFetch(OAUTH_CONFIG.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: OAUTH_CONFIG.clientId, + client_secret: OAUTH_CONFIG.clientSecret, + code: code, + code_verifier: verifier, + grant_type: 'authorization_code', + redirect_uri: OAUTH_REDIRECT_URI + }) + }); + + if (!response.ok) { + const error = await response.text(); + logger.error(`[OAuth] Token exchange failed: ${response.status} ${error}`); + throw new Error(`Token exchange failed: ${error}`); + } + + const tokens = await response.json(); + + if (!tokens.access_token) { + logger.error('[OAuth] No access token in response:', tokens); + throw new Error('No access token received'); + } + + logger.info(`[OAuth] Token exchange successful, access_token length: ${tokens.access_token?.length}`); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in + }; +} + +/** + * Refresh access token using refresh token + * Handles composite refresh tokens (refreshToken|projectId|managedProjectId) + * + * @param {string} compositeRefresh - OAuth refresh token (may be composite) + * @returns {Promise<{accessToken: string, expiresIn: number}>} New access token + */ +export async function refreshAccessToken(compositeRefresh) { + // Parse the composite refresh token to extract the actual OAuth token + const parts = parseRefreshParts(compositeRefresh); + + const response = await throttledFetch(OAUTH_CONFIG.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + client_id: OAUTH_CONFIG.clientId, + client_secret: OAUTH_CONFIG.clientSecret, + refresh_token: parts.refreshToken, // Use the actual OAuth token + grant_type: 'refresh_token' + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + const tokens = await response.json(); + return { + accessToken: tokens.access_token, + expiresIn: tokens.expires_in + }; +} + +/** + * Get user email from access token + * + * @param {string} accessToken - OAuth access token + * @returns {Promise} User's email address + */ +export async function getUserEmail(accessToken) { + const response = await throttledFetch(OAUTH_CONFIG.userInfoUrl, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`[OAuth] getUserEmail failed: ${response.status} ${errorText}`); + throw new Error(`Failed to get user info: ${response.status}`); + } + + const userInfo = await response.json(); + return userInfo.email; +} + +/** + * Discover project ID for the authenticated user + * + * @param {string} accessToken - OAuth access token + * @returns {Promise} Project ID or null if not found + */ +export async function discoverProjectId(accessToken) { + let loadCodeAssistData = null; + + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const response = await throttledFetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...LOAD_CODE_ASSIST_HEADERS + }, + body: JSON.stringify({ + metadata: CLIENT_METADATA + }) + }); + + if (!response.ok) continue; + + const data = await response.json(); + loadCodeAssistData = data; + + if (typeof data.cloudaicompanionProject === 'string') { + return data.cloudaicompanionProject; + } + if (data.cloudaicompanionProject?.id) { + return data.cloudaicompanionProject.id; + } + + // No project found - try to onboard + logger.info('[OAuth] No project in loadCodeAssist response, attempting onboardUser...'); + break; + } catch (error) { + logger.warn(`[OAuth] Project discovery failed at ${endpoint}:`, error.message); + } + } + + // Try onboarding if we got a response but no project + if (loadCodeAssistData) { + const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'FREE'; + logger.info(`[OAuth] Onboarding user with tier: ${tierId}`); + + const onboardedProject = await onboardUser(accessToken, tierId); + if (onboardedProject) { + logger.success(`[OAuth] Successfully onboarded, project: ${onboardedProject}`); + return onboardedProject; + } + } + + return null; +} + +/** + * Complete OAuth flow: exchange code and get all account info + * + * @param {string} code - Authorization code from OAuth callback + * @param {string} verifier - PKCE code verifier + * @returns {Promise<{email: string, refreshToken: string, accessToken: string, projectId: string|null}>} Complete account info + */ +export async function completeOAuthFlow(code, verifier) { + // Exchange code for tokens + const tokens = await exchangeCode(code, verifier); + + // Get user email + const email = await getUserEmail(tokens.accessToken); + + // Discover project ID + const projectId = await discoverProjectId(tokens.accessToken); + + return { + email, + refreshToken: tokens.refreshToken, + accessToken: tokens.accessToken, + projectId + }; +} + +export default { + parseRefreshParts, + formatRefreshParts, + getAuthorizationUrl, + extractCodeFromInput, + startCallbackServer, + exchangeCode, + refreshAccessToken, + getUserEmail, + discoverProjectId, + completeOAuthFlow +}; diff --git a/src/auth/token-extractor.js b/src/auth/token-extractor.js new file mode 100755 index 0000000000000000000000000000000000000000..cface190cf27558b9d1c1550dda9259939595bd5 --- /dev/null +++ b/src/auth/token-extractor.js @@ -0,0 +1,117 @@ +/** + * Token Extractor Module + * Extracts OAuth tokens from Antigravity's SQLite database + * + * The database is automatically updated by Antigravity when tokens refresh, + * so this approach doesn't require any manual intervention. + */ + +import { + TOKEN_REFRESH_INTERVAL_MS, + ANTIGRAVITY_AUTH_PORT +} from '../constants.js'; +import { getAuthStatus } from './database.js'; +import { logger } from '../utils/logger.js'; + +// Cache for the extracted token +let cachedToken = null; +let tokenExtractedAt = null; + +/** + * Extract the chat params from Antigravity's HTML page (fallback method) + */ +async function extractChatParams() { + try { + const response = await fetch(`http://127.0.0.1:${ANTIGRAVITY_AUTH_PORT}/`); + const html = await response.text(); + + // Find the base64-encoded chatParams in the HTML + const match = html.match(/window\.chatParams\s*=\s*'([^']+)'/); + if (!match) { + throw new Error('Could not find chatParams in Antigravity page'); + } + + // Decode base64 + const base64Data = match[1]; + const jsonString = Buffer.from(base64Data, 'base64').toString('utf-8'); + const config = JSON.parse(jsonString); + + return config; + } catch (error) { + if (error.code === 'ECONNREFUSED') { + throw new Error( + `Cannot connect to Antigravity on port ${ANTIGRAVITY_AUTH_PORT}. ` + + 'Make sure Antigravity is running.' + ); + } + throw error; + } +} + +/** + * Get fresh token data - tries DB first, falls back to HTML page + */ +async function getTokenData() { + // Try database first (preferred - always has fresh token) + try { + const dbData = getAuthStatus(); + if (dbData?.apiKey) { + logger.info('[Token] Got fresh token from SQLite database'); + return dbData; + } + } catch (err) { + logger.warn('[Token] DB extraction failed, trying HTML page...'); + } + + // Fallback to HTML page + try { + const pageData = await extractChatParams(); + if (pageData?.apiKey) { + logger.warn('[Token] Got token from HTML page (may be stale)'); + return pageData; + } + } catch (err) { + logger.warn(`[Token] HTML page extraction failed: ${err.message}`); + } + + throw new Error( + 'Could not extract token from Antigravity. ' + + 'Make sure Antigravity is running and you are logged in.' + ); +} + +/** + * Check if the cached token needs refresh + */ +function needsRefresh() { + if (!cachedToken || !tokenExtractedAt) { + return true; + } + return Date.now() - tokenExtractedAt > TOKEN_REFRESH_INTERVAL_MS; +} + +/** + * Get the current OAuth token (with caching) + */ +export async function getToken() { + if (needsRefresh()) { + const data = await getTokenData(); + cachedToken = data.apiKey; + tokenExtractedAt = Date.now(); + } + return cachedToken; +} + +/** + * Force refresh the token (useful if requests start failing) + */ +export async function forceRefresh() { + cachedToken = null; + tokenExtractedAt = null; + return getToken(); +} + +export default { + getToken, + forceRefresh +}; diff --git a/src/bootstrap/env-accounts.js b/src/bootstrap/env-accounts.js new file mode 100755 index 0000000000000000000000000000000000000000..7c6f6719cc76f4559c7740f0ecd7c26a15460aa3 --- /dev/null +++ b/src/bootstrap/env-accounts.js @@ -0,0 +1,133 @@ +/** + * Bootstrap Antigravity OAuth accounts from environment variables. + */ + +import { parseRefreshParts, formatRefreshParts } from '../auth/oauth.js'; +import { loadAccounts, saveAccounts } from '../account-manager/storage.js'; +import { logger } from '../utils/logger.js'; + +function envValue(env, key) { + const value = env[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function isTruthy(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()); +} + +function normalizeTier(value) { + const tier = String(value || '').trim().toLowerCase(); + if (tier.includes('ultra')) return 'ultra'; + if (tier.includes('pro') || tier.includes('premium') || tier.includes('standard')) return 'pro'; + if (tier.includes('free')) return 'free'; + return 'unknown'; +} + +function findCredentialIndexes(env) { + const indexes = new Set(); + for (const key of Object.keys(env)) { + const match = key.match(/^ANTIGRAVITY_(\d+)_REFRESH_TOKEN$/); + if (match && envValue(env, key)) indexes.add(Number(match[1])); + } + + if (indexes.size === 0 && envValue(env, 'ANTIGRAVITY_REFRESH_TOKEN')) { + indexes.add(0); + } + + return [...indexes].sort((a, b) => a - b); +} + +export function collectEnvAccounts(env = process.env) { + const accounts = []; + + for (const index of findCredentialIndexes(env)) { + const prefix = index === 0 ? 'ANTIGRAVITY' : `ANTIGRAVITY_${index}`; + const email = envValue(env, `${prefix}_EMAIL`); + const rawRefreshToken = envValue(env, `${prefix}_REFRESH_TOKEN`); + const projectId = envValue(env, `${prefix}_PROJECT_ID`); + const tier = normalizeTier(envValue(env, `${prefix}_TIER`)); + + if (!email) { + throw new Error(`${prefix}_EMAIL is required when ${prefix}_REFRESH_TOKEN is set`); + } + if (!rawRefreshToken) continue; + + const existingParts = parseRefreshParts(rawRefreshToken); + const resolvedProjectId = projectId + || existingParts.managedProjectId + || existingParts.projectId; + const refreshToken = formatRefreshParts({ + refreshToken: existingParts.refreshToken, + projectId: existingParts.projectId || resolvedProjectId, + managedProjectId: existingParts.managedProjectId || resolvedProjectId + }); + + accounts.push({ + email, + source: 'oauth', + enabled: true, + refreshToken, + projectId: resolvedProjectId || undefined, + addedAt: new Date().toISOString(), + isInvalid: false, + invalidReason: null, + verifyUrl: null, + modelRateLimits: {}, + lastUsed: null, + subscription: { + tier, + projectId: resolvedProjectId || null, + detectedAt: tier !== 'unknown' || resolvedProjectId ? Date.now() : null + }, + quota: { + models: {}, + lastChecked: null + }, + modelQuotaThresholds: {} + }); + } + + return accounts; +} + +export async function bootstrapEnvAccounts(configPath, env = process.env) { + const envAccounts = collectEnvAccounts(env); + if (envAccounts.length === 0) { + return { changed: false, count: 0 }; + } + + const force = isTruthy(env.FORCE_ENV_ACCOUNTS); + const { accounts: existingAccounts, settings, activeIndex } = await loadAccounts(configPath); + const merged = force ? [] : [...existingAccounts]; + + for (const envAccount of envAccounts) { + const existingIndex = merged.findIndex(account => account.email === envAccount.email); + if (existingIndex === -1) { + merged.push(envAccount); + } else { + merged[existingIndex] = { + ...merged[existingIndex], + ...envAccount, + addedAt: merged[existingIndex].addedAt || envAccount.addedAt, + modelRateLimits: force ? {} : (merged[existingIndex].modelRateLimits || {}), + lastUsed: force ? null : (merged[existingIndex].lastUsed || null), + quota: force ? envAccount.quota : (merged[existingIndex].quota || envAccount.quota) + }; + } + } + + await saveAccounts(configPath, merged, settings, Math.min(activeIndex, Math.max(0, merged.length - 1))); + + // saveAccounts logs and swallows I/O errors, so verify persistence explicitly. + const { accounts: persistedAccounts } = await loadAccounts(configPath); + const persistedEmails = new Set(persistedAccounts.map(account => account.email)); + const missingEmails = envAccounts + .map(account => account.email) + .filter(email => !persistedEmails.has(email)); + if (missingEmails.length > 0) { + throw new Error(`Failed to persist environment accounts: ${missingEmails.join(', ')}`); + } + + logger.info(`[Bootstrap] Loaded ${envAccounts.length} Antigravity account(s) from environment${force ? ' (authoritative)' : ''}`); + return { changed: true, count: envAccounts.length }; +} diff --git a/src/bootstrap/load-config-env.js b/src/bootstrap/load-config-env.js new file mode 100755 index 0000000000000000000000000000000000000000..bb2ae8b9a3a11d690cde366ba374fb8a4a9fa4c6 --- /dev/null +++ b/src/bootstrap/load-config-env.js @@ -0,0 +1,58 @@ +/** + * Load a bulk CONFIG_ENV secret into process.env. + * + * This module intentionally does not write CONFIG_ENV to disk. Explicit + * environment variables always win over values from CONFIG_ENV. + */ + +function unquote(value) { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + } + return value; +} + +export function parseConfigEnvBlock(block) { + const parsed = {}; + if (!block || typeof block !== 'string') return parsed; + + for (const rawLine of block.split(/\r?\n/)) { + let line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + if (line.startsWith('export ')) line = line.slice(7).trim(); + + const separatorIndex = line.indexOf('='); + if (separatorIndex <= 0) continue; + + const key = line.slice(0, separatorIndex).trim(); + const value = unquote(line.slice(separatorIndex + 1).trim()); + + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + parsed[key] = value; + } + + return parsed; +} + +export function applyConfigEnv(block = process.env.CONFIG_ENV, target = process.env) { + const parsed = parseConfigEnvBlock(block); + let applied = 0; + + for (const [key, value] of Object.entries(parsed)) { + if (target[key] === undefined || target[key] === '') { + target[key] = value; + applied++; + } + } + + return { parsed: Object.keys(parsed).length, applied }; +} + +const result = applyConfigEnv(); +if (result.applied > 0) { + console.info(`[Bootstrap] Loaded ${result.applied} variable(s) from CONFIG_ENV`); +} diff --git a/src/cli/accounts.js b/src/cli/accounts.js new file mode 100755 index 0000000000000000000000000000000000000000..fad81b83798f274c4d4dca91f778a591ed90e3e8 --- /dev/null +++ b/src/cli/accounts.js @@ -0,0 +1,510 @@ +#!/usr/bin/env node + +/** + * Account Management CLI + * + * Interactive CLI for adding and managing Google accounts + * for the Antigravity Claude Proxy. + * + * Usage: + * node src/cli/accounts.js # Interactive mode + * node src/cli/accounts.js add # Add new account(s) + * node src/cli/accounts.js list # List all accounts + * node src/cli/accounts.js clear # Remove all accounts + */ +import '../utils/proxy.js'; +import { createInterface } from 'readline/promises'; +import { stdin, stdout } from 'process'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { dirname } from 'path'; +import { spawn } from 'child_process'; +import net from 'net'; +import { ACCOUNT_CONFIG_PATH, DEFAULT_PORT, MAX_ACCOUNTS } from '../constants.js'; +import { + getAuthorizationUrl, + startCallbackServer, + completeOAuthFlow, + refreshAccessToken, + getUserEmail, + extractCodeFromInput +} from '../auth/oauth.js'; + +const SERVER_PORT = process.env.PORT || DEFAULT_PORT; + +/** + * Check if the Antigravity Proxy server is running + * Returns true if port is occupied + */ +function isServerRunning() { + return new Promise((resolve) => { + const socket = new net.Socket(); + socket.setTimeout(1000); + + socket.on('connect', () => { + socket.destroy(); + resolve(true); // Server is running + }); + + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + + socket.on('error', (err) => { + socket.destroy(); + resolve(false); // Port free + }); + + socket.connect(SERVER_PORT, 'localhost'); + }); +} + +/** + * Enforce that server is stopped before proceeding + */ +async function ensureServerStopped() { + const isRunning = await isServerRunning(); + if (isRunning) { + console.error(` +\x1b[31mError: Antigravity Proxy server is currently running on port ${SERVER_PORT}.\x1b[0m + +Please stop the server (Ctrl+C) before adding or managing accounts. +This ensures that your account changes are loaded correctly when you restart the server. +`); + process.exit(1); + } +} + +/** + * Create readline interface + */ +function createRL() { + return createInterface({ input: stdin, output: stdout }); +} + +/** + * Open URL in default browser + */ +function openBrowser(url) { + const platform = process.platform; + let command; + let args; + + if (platform === 'darwin') { + command = 'open'; + args = [url]; + } else if (platform === 'win32') { + command = 'cmd'; + args = ['/c', 'start', '', url.replace(/&/g, '^&')]; + } else { + command = 'xdg-open'; + args = [url]; + } + + const child = spawn(command, args, { stdio: 'ignore', detached: true }); + child.on('error', () => { + console.log('\n⚠ Could not open browser automatically.'); + console.log('Please open this URL manually:', url); + }); + child.unref(); +} + +/** + * Load existing accounts from config + */ +function loadAccounts() { + try { + if (existsSync(ACCOUNT_CONFIG_PATH)) { + const data = readFileSync(ACCOUNT_CONFIG_PATH, 'utf-8'); + const config = JSON.parse(data); + return config.accounts || []; + } + } catch (error) { + console.error('Error loading accounts:', error.message); + } + return []; +} + +/** + * Save accounts to config + */ +function saveAccounts(accounts, settings = {}) { + try { + const dir = dirname(ACCOUNT_CONFIG_PATH); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const config = { + accounts: accounts.map(acc => ({ + email: acc.email, + source: 'oauth', + refreshToken: acc.refreshToken, + projectId: acc.projectId, + addedAt: acc.addedAt || new Date().toISOString(), + lastUsed: acc.lastUsed || null, + modelRateLimits: acc.modelRateLimits || {} + })), + settings: { + maxRetries: 5, + ...settings + }, + activeIndex: 0 + }; + + writeFileSync(ACCOUNT_CONFIG_PATH, JSON.stringify(config, null, 2)); + console.log(`\n✓ Saved ${accounts.length} account(s) to ${ACCOUNT_CONFIG_PATH}`); + } catch (error) { + console.error('Error saving accounts:', error.message); + throw error; + } +} + +/** + * Display current accounts + */ +function displayAccounts(accounts) { + if (accounts.length === 0) { + console.log('\nNo accounts configured.'); + return; + } + + console.log(`\n${accounts.length} account(s) saved:`); + accounts.forEach((acc, i) => { + // Check for any active model-specific rate limits + const hasActiveLimit = Object.values(acc.modelRateLimits || {}).some( + limit => limit.isRateLimited && limit.resetTime > Date.now() + ); + const status = hasActiveLimit ? ' (rate-limited)' : ''; + console.log(` ${i + 1}. ${acc.email}${status}`); + }); +} + +/** + * Add a new account via OAuth with automatic callback + */ +async function addAccount(existingAccounts) { + console.log('\n=== Add Google Account ===\n'); + + // Generate authorization URL + const { url, verifier, state } = getAuthorizationUrl(); + + console.log('Opening browser for Google sign-in...'); + console.log('(If browser does not open, copy this URL manually)\n'); + console.log(` ${url}\n`); + + // Open browser + openBrowser(url); + + // Start callback server and wait for code + console.log('Waiting for authentication (timeout: 2 minutes)...\n'); + + try { + // startCallbackServer now returns { promise, abort } + const { promise } = startCallbackServer(state); + const code = await promise; + + console.log('Received authorization code. Exchanging for tokens...'); + const result = await completeOAuthFlow(code, verifier); + + // Check if account already exists + const existing = existingAccounts.find(a => a.email === result.email); + if (existing) { + console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`); + existing.refreshToken = result.refreshToken; + // Note: projectId will be discovered and stored in refresh token on first use + existing.addedAt = new Date().toISOString(); + return null; // Don't add duplicate + } + + console.log(`\n✓ Successfully authenticated: ${result.email}`); + console.log(' Project will be discovered on first API request.'); + + return { + email: result.email, + refreshToken: result.refreshToken, + // Note: projectId stored in refresh token, not as separate field + addedAt: new Date().toISOString(), + modelRateLimits: {} + }; + } catch (error) { + console.error(`\n✗ Authentication failed: ${error.message}`); + return null; + } +} + +/** + * Add a new account via OAuth with manual code input (no-browser mode) + * For headless servers without a desktop environment + */ +async function addAccountNoBrowser(existingAccounts, rl) { + console.log('\n=== Add Google Account (No-Browser Mode) ===\n'); + + // Generate authorization URL + const { url, verifier, state } = getAuthorizationUrl(); + + console.log('Copy the following URL and open it in a browser on another device:\n'); + console.log(` ${url}\n`); + console.log('After signing in, you will be redirected to a localhost URL.'); + console.log('Copy the ENTIRE redirect URL or just the authorization code.\n'); + + const input = await rl.question('Paste the callback URL or authorization code: '); + + try { + const { code, state: extractedState } = extractCodeFromInput(input); + + // Validate state if present + if (extractedState && extractedState !== state) { + console.log('\n⚠ State mismatch detected. This could indicate a security issue.'); + console.log('Proceeding anyway as this is manual mode...'); + } + + console.log('\nExchanging authorization code for tokens...'); + const result = await completeOAuthFlow(code, verifier); + + // Check if account already exists + const existing = existingAccounts.find(a => a.email === result.email); + if (existing) { + console.log(`\n⚠ Account ${result.email} already exists. Updating tokens.`); + existing.refreshToken = result.refreshToken; + // Note: projectId will be discovered and stored in refresh token on first use + existing.addedAt = new Date().toISOString(); + return null; // Don't add duplicate + } + + console.log(`\n✓ Successfully authenticated: ${result.email}`); + console.log(' Project will be discovered on first API request.'); + + return { + email: result.email, + refreshToken: result.refreshToken, + // Note: projectId stored in refresh token, not as separate field + addedAt: new Date().toISOString(), + modelRateLimits: {} + }; + } catch (error) { + console.error(`\n✗ Authentication failed: ${error.message}`); + return null; + } +} + +/** + * Interactive remove accounts flow + */ +async function interactiveRemove(rl) { + while (true) { + const accounts = loadAccounts(); + if (accounts.length === 0) { + console.log('\nNo accounts to remove.'); + return; + } + + displayAccounts(accounts); + console.log('\nEnter account number to remove (or 0 to cancel)'); + + const answer = await rl.question('> '); + const index = parseInt(answer, 10); + + if (isNaN(index) || index < 0 || index > accounts.length) { + console.log('\n❌ Invalid selection.'); + continue; + } + + if (index === 0) { + return; // Exit + } + + const removed = accounts[index - 1]; // 1-based to 0-based + const confirm = await rl.question(`\nAre you sure you want to remove ${removed.email}? [y/N]: `); + + if (confirm.toLowerCase() === 'y') { + accounts.splice(index - 1, 1); + saveAccounts(accounts); + console.log(`\n✓ Removed ${removed.email}`); + } else { + console.log('\nCancelled.'); + } + + const removeMore = await rl.question('\nRemove another account? [y/N]: '); + if (removeMore.toLowerCase() !== 'y') { + break; + } + } +} + +/** + * Interactive add accounts flow (Main Menu) + * @param {Object} rl - readline interface + * @param {boolean} noBrowser - if true, use manual code input mode + */ +async function interactiveAdd(rl, noBrowser = false) { + if (noBrowser) { + console.log('\n📋 No-browser mode: You will manually paste the authorization code.\n'); + } + + const accounts = loadAccounts(); + + if (accounts.length > 0) { + displayAccounts(accounts); + + const choice = await rl.question('\n(a)dd new, (r)emove existing, (f)resh start, or (e)xit? [a/r/f/e]: '); + const c = choice.toLowerCase(); + + if (c === 'r') { + await interactiveRemove(rl); + return; // Return to main or exit? Given this is "add", we probably exit after sub-task. + } else if (c === 'f') { + console.log('\nStarting fresh - existing accounts will be replaced.'); + accounts.length = 0; + } else if (c === 'a') { + console.log('\nAdding to existing accounts.'); + } else if (c === 'e') { + console.log('\nExiting...'); + return; // Exit cleanly + } else { + console.log('\nInvalid choice, defaulting to add.'); + } + } + + // Add single account + if (accounts.length >= MAX_ACCOUNTS) { + console.log(`\nMaximum of ${MAX_ACCOUNTS} accounts reached.`); + return; + } + + // Use appropriate add function based on mode + const newAccount = noBrowser + ? await addAccountNoBrowser(accounts, rl) + : await addAccount(accounts); + + if (newAccount) { + accounts.push(newAccount); + saveAccounts(accounts); + } else if (accounts.length > 0) { + // Even if newAccount is null (duplicate update), save the updated accounts + saveAccounts(accounts); + } + + if (accounts.length > 0) { + displayAccounts(accounts); + console.log('\nTo add more accounts, run this command again.'); + } else { + console.log('\nNo accounts to save.'); + } +} + +/** + * List accounts + */ +async function listAccounts() { + const accounts = loadAccounts(); + displayAccounts(accounts); + + if (accounts.length > 0) { + console.log(`\nConfig file: ${ACCOUNT_CONFIG_PATH}`); + } +} + +/** + * Clear all accounts + */ +async function clearAccounts(rl) { + const accounts = loadAccounts(); + + if (accounts.length === 0) { + console.log('No accounts to clear.'); + return; + } + + displayAccounts(accounts); + + const confirm = await rl.question('\nAre you sure you want to remove all accounts? [y/N]: '); + if (confirm.toLowerCase() === 'y') { + saveAccounts([]); + console.log('All accounts removed.'); + } else { + console.log('Cancelled.'); + } +} + +/** + * Verify accounts (test refresh tokens) + */ +async function verifyAccounts() { + const accounts = loadAccounts(); + + if (accounts.length === 0) { + console.log('No accounts to verify.'); + return; + } + + console.log('\nVerifying accounts...\n'); + + for (const account of accounts) { + try { + const tokens = await refreshAccessToken(account.refreshToken); + const email = await getUserEmail(tokens.accessToken); + console.log(` ✓ ${email} - OK`); + } catch (error) { + console.log(` ✗ ${account.email} - ${error.message}`); + } + } +} + +/** + * Main CLI + */ +async function main() { + const args = process.argv.slice(2); + const command = args[0] || 'add'; + const noBrowser = args.includes('--no-browser'); + + console.log('╔════════════════════════════════════════╗'); + console.log('║ Antigravity Proxy Account Manager ║'); + console.log('║ Use --no-browser for headless mode ║'); + console.log('╚════════════════════════════════════════╝'); + + const rl = createRL(); + + try { + switch (command) { + case 'add': + await ensureServerStopped(); + await interactiveAdd(rl, noBrowser); + break; + case 'list': + await listAccounts(); + break; + case 'clear': + await ensureServerStopped(); + await clearAccounts(rl); + break; + case 'verify': + await verifyAccounts(); + break; + case 'help': + console.log('\nUsage:'); + console.log(' node src/cli/accounts.js add Add new account(s)'); + console.log(' node src/cli/accounts.js list List all accounts'); + console.log(' node src/cli/accounts.js verify Verify account tokens'); + console.log(' node src/cli/accounts.js clear Remove all accounts'); + console.log(' node src/cli/accounts.js help Show this help'); + console.log('\nOptions:'); + console.log(' --no-browser Manual authorization code input (for headless servers)'); + break; + case 'remove': + await ensureServerStopped(); + await interactiveRemove(rl); + break; + default: + console.log(`Unknown command: ${command}`); + console.log('Run with "help" for usage information.'); + } + } finally { + rl.close(); + // Force exit to prevent hanging + process.exit(0); + } +} + +main().catch(console.error); diff --git a/src/cloudcode/index.js b/src/cloudcode/index.js new file mode 100755 index 0000000000000000000000000000000000000000..b1d43f52f4dd4306b6b18de7968323eda526d35a --- /dev/null +++ b/src/cloudcode/index.js @@ -0,0 +1,30 @@ +/** + * Cloud Code Client for Antigravity + * + * Communicates with Google's Cloud Code internal API using the + * v1internal:streamGenerateContent endpoint with proper request wrapping. + * + * Supports multi-account load balancing with automatic failover. + * + * Based on: https://github.com/NoeFabris/opencode-antigravity-auth + */ + +// Re-export public API +export { sendMessage } from './message-handler.js'; +export { sendMessageStream } from './streaming-handler.js'; +export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier, isValidModel } from './model-api.js'; + +// Default export for backwards compatibility +import { sendMessage } from './message-handler.js'; +import { sendMessageStream } from './streaming-handler.js'; +import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier, isValidModel } from './model-api.js'; + +export default { + sendMessage, + sendMessageStream, + listModels, + fetchAvailableModels, + getModelQuotas, + getSubscriptionTier, + isValidModel +}; diff --git a/src/cloudcode/message-handler.js b/src/cloudcode/message-handler.js new file mode 100755 index 0000000000000000000000000000000000000000..16ad3a95d741138a4574bdc04345748207192eda --- /dev/null +++ b/src/cloudcode/message-handler.js @@ -0,0 +1,444 @@ +/** + * Message Handler for Cloud Code + * + * Handles non-streaming message requests with multi-account support, + * retry logic, and endpoint failover. + */ + +import { + ANTIGRAVITY_ENDPOINT_FALLBACKS, + MAX_RETRIES, + MAX_WAIT_BEFORE_ERROR_MS, + DEFAULT_COOLDOWN_MS, + SWITCH_ACCOUNT_DELAY_MS, + MAX_CONSECUTIVE_FAILURES, + EXTENDED_COOLDOWN_MS, + CAPACITY_BACKOFF_TIERS_MS, + MAX_CAPACITY_RETRIES, + BACKOFF_BY_ERROR_TYPE, + isThinkingModel +} from '../constants.js'; +import { convertGoogleToAnthropic } from '../format/index.js'; +import { isRateLimitError, isAuthError, isAccountForbiddenError, AccountForbiddenError } from '../errors.js'; +import { formatDuration, sleep, isNetworkError, throttledFetch } from '../utils/helpers.js'; +import { logger } from '../utils/logger.js'; +import { parseResetTime } from './rate-limit-parser.js'; +import { buildCloudCodeRequest, buildHeaders } from './request-builder.js'; +import { parseThinkingSSEResponse } from './sse-parser.js'; +import { getFallbackModel } from '../fallback-config.js'; +import { + getRateLimitBackoff, + clearRateLimitState, + isPermanentAuthFailure, + isModelCapacityExhausted, + isValidationRequired, + extractVerificationUrl, + isAccountBanned, + calculateSmartBackoff +} from './rate-limit-state.js'; + +/** + * Send a non-streaming request to Cloud Code with multi-account support + * Uses SSE endpoint for thinking models (non-streaming doesn't return thinking blocks) + * + * @param {Object} anthropicRequest - The Anthropic-format request + * @param {Object} anthropicRequest.model - Model name to use + * @param {Array} anthropicRequest.messages - Array of message objects + * @param {number} [anthropicRequest.max_tokens] - Maximum tokens to generate + * @param {Object} [anthropicRequest.thinking] - Thinking configuration + * @param {import('../account-manager/index.js').default} accountManager - The account manager instance + * @returns {Promise} Anthropic-format response object + * @throws {Error} If max retries exceeded or no accounts available + */ +export async function sendMessage(anthropicRequest, accountManager, fallbackEnabled = false) { + const model = anthropicRequest.model; + const isThinking = isThinkingModel(model); + + // Retry loop with account failover + // Ensure we try at least as many times as there are accounts to cycle through everyone + const maxAttempts = Math.max(MAX_RETRIES, accountManager.getAccountCount() + 1); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // Clear any expired rate limits before picking + accountManager.clearExpiredLimits(); + + // Get available accounts for this model + const availableAccounts = accountManager.getAvailableAccounts(model); + + // If no accounts available, check if we should wait or throw error + if (availableAccounts.length === 0) { + // All accounts invalid? Fail immediately — they need user intervention (WebUI FIX button) + // Invalid accounts won't self-recover, so waiting would be an infinite loop + if (accountManager.isAllAccountsInvalid()) { + const invalidAccounts = accountManager.getInvalidAccounts(); + const reasons = [...new Set(invalidAccounts.map(a => a.invalidReason).filter(Boolean))]; + throw new Error( + `All accounts are invalid: ${reasons.join('; ') || 'unknown reason'}. Visit the WebUI to fix them.` + ); + } + + if (accountManager.isAllRateLimited(model)) { + const minWaitMs = accountManager.getMinWaitTimeMs(model); + const resetTime = new Date(Date.now() + minWaitMs).toISOString(); + + // If wait time is too long (> 2 minutes), try fallback first, then throw error + if (minWaitMs > MAX_WAIT_BEFORE_ERROR_MS) { + // Check if fallback is enabled and available + if (fallbackEnabled) { + const fallbackModel = getFallbackModel(model); + if (fallbackModel) { + logger.warn(`[CloudCode] All accounts exhausted for ${model} (${formatDuration(minWaitMs)} wait). Attempting fallback to ${fallbackModel}`); + const fallbackRequest = { ...anthropicRequest, model: fallbackModel }; + return await sendMessage(fallbackRequest, accountManager, false); + } + } + throw new Error( + `RESOURCE_EXHAUSTED: Rate limited on ${model}. Quota will reset after ${formatDuration(minWaitMs)}. Next available: ${resetTime}` + ); + } + + // Wait for shortest reset time + const accountCount = accountManager.getAccountCount(); + logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`); + await sleep(minWaitMs + 500); // Add 500ms buffer + accountManager.clearExpiredLimits(); + + // CRITICAL FIX: Don't count waiting for rate limits as a failed attempt + // This prevents "Max retries exceeded" when we are just patiently waiting + attempt--; + continue; // Retry the loop + } + + // No accounts available and not rate-limited (shouldn't happen normally) + throw new Error('No accounts available'); + } + + // Select account using configured strategy + const { account, waitMs } = accountManager.selectAccount(model); + + // If strategy returns a wait time without an account, sleep and retry + if (!account && waitMs > 0) { + logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`); + await sleep(waitMs + 500); + attempt--; // CRITICAL FIX: Don't count strategy wait as failure + continue; + } + + // If strategy returns an account with throttle wait (fallback mode), apply delay + // This prevents overwhelming the API when using emergency/lastResort fallbacks + if (account && waitMs > 0) { + logger.debug(`[CloudCode] Throttling request (${waitMs}ms) - fallback mode active`); + await sleep(waitMs); + } + + if (!account) { + logger.warn(`[CloudCode] Strategy returned no account for ${model} (attempt ${attempt + 1}/${maxAttempts})`); + continue; + } + + try { + // Get token and project for this account + const token = await accountManager.getTokenForAccount(account); + const project = await accountManager.getProjectForAccount(account, token); + const payload = buildCloudCodeRequest(anthropicRequest, project, account.email); + + logger.debug(`[CloudCode] Sending request for model: ${model}`); + + // Try each endpoint with index-based loop for capacity retry support + let lastError = null; + let capacityRetryCount = 0; + let endpointIndex = 0; + + while (endpointIndex < ANTIGRAVITY_ENDPOINT_FALLBACKS.length) { + const endpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[endpointIndex]; + try { + const url = isThinking + ? `${endpoint}/v1internal:streamGenerateContent?alt=sse` + : `${endpoint}/v1internal:generateContent`; + + const response = await throttledFetch(url, { + method: 'POST', + headers: buildHeaders(token, model, isThinking ? 'text/event-stream' : 'application/json', payload.request.sessionId), + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.warn(`[CloudCode] Error at ${endpoint}: ${response.status} - ${errorText}`); + // #region agent log + fetch('http://127.0.0.1:7334/ingest/27587085-8299-4f8d-827a-e9584444390b',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'c04864'},body:JSON.stringify({sessionId:'c04864',location:'message-handler.js:upstream-error',message:'Cloud Code upstream error',data:{model,endpoint,status:response.status,isThinking,thinkingConfig:payload?.request?.generationConfig?.thinkingConfig||null,errorPreview:errorText.slice(0,240)},timestamp:Date.now(),hypothesisId:'H1-H3'})}).catch(()=>{}); + // #endregion + + if (response.status === 401) { + // Check for permanent auth failures + if (isPermanentAuthFailure(errorText)) { + logger.error(`[CloudCode] Permanent auth failure for ${account.email}: ${errorText.substring(0, 100)}`); + accountManager.markInvalid(account.email, 'Token revoked - re-authentication required'); + throw new Error(`AUTH_INVALID_PERMANENT: ${errorText}`); + } + + // Transient auth error - clear caches and retry with fresh token + logger.warn('[CloudCode] Transient auth error, refreshing token...'); + accountManager.clearTokenCache(account.email); + accountManager.clearProjectCache(account.email); + endpointIndex++; + continue; + } + + if (response.status === 429) { + const resetMs = parseResetTime(response, errorText); + const consecutiveFailures = accountManager.getConsecutiveFailures?.(account.email) || 0; + + // Check if capacity issue (NOT quota) - retry same endpoint with progressive backoff + if (isModelCapacityExhausted(errorText)) { + if (capacityRetryCount < MAX_CAPACITY_RETRIES) { + // Progressive capacity backoff tiers + const tierIndex = Math.min(capacityRetryCount, CAPACITY_BACKOFF_TIERS_MS.length - 1); + const waitMs = resetMs || CAPACITY_BACKOFF_TIERS_MS[tierIndex]; + capacityRetryCount++; + // Track failures for progressive backoff escalation (matches opencode-antigravity-auth) + accountManager.incrementConsecutiveFailures(account.email); + logger.info(`[CloudCode] Model capacity exhausted, retry ${capacityRetryCount}/${MAX_CAPACITY_RETRIES} after ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + // Max capacity retries exceeded - treat as quota exhaustion + logger.warn(`[CloudCode] Max capacity retries (${MAX_CAPACITY_RETRIES}) exceeded, switching account`); + } + + // Get rate limit backoff with exponential backoff and state reset + const backoff = getRateLimitBackoff(account.email, model, resetMs); + + // For very short rate limits (< 1 second), always wait and retry + // Switching accounts won't help when all accounts have per-second rate limits + if (resetMs !== null && resetMs < 1000) { + const waitMs = resetMs; + logger.info(`[CloudCode] Short rate limit on ${account.email} (${resetMs}ms), waiting and retrying...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + + // If within dedup window AND reset time is >= 1s, switch account + if (backoff.isDuplicate) { + const smartBackoffMs = calculateSmartBackoff(errorText, resetMs, consecutiveFailures); + logger.info(`[CloudCode] Skipping retry due to recent rate limit on ${account.email} (attempt ${backoff.attempt}), switching account...`); + accountManager.markRateLimited(account.email, smartBackoffMs, model); + throw new Error(`RATE_LIMITED_DEDUP: ${errorText}`); + } + + // Calculate smart backoff based on error type + const smartBackoffMs = calculateSmartBackoff(errorText, resetMs, consecutiveFailures); + + // Decision: wait and retry OR switch account + // First 429 gets a quick 1s retry (FIRST_RETRY_DELAY_MS) + if (backoff.attempt === 1 && smartBackoffMs <= DEFAULT_COOLDOWN_MS) { + // Quick 1s retry on first 429 (matches opencode-antigravity-auth) + const waitMs = backoff.delayMs; + // markRateLimited already increments consecutiveFailures internally + // This prevents concurrent retry storms and ensures progressive backoff escalation + accountManager.markRateLimited(account.email, waitMs, model); + logger.info(`[CloudCode] First rate limit on ${account.email}, quick retry after ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } else if (smartBackoffMs > DEFAULT_COOLDOWN_MS) { + // Long-term quota exhaustion (> 10s) - wait SWITCH_ACCOUNT_DELAY_MS then switch + logger.info(`[CloudCode] Quota exhausted for ${account.email} (${formatDuration(smartBackoffMs)}), switching account after ${formatDuration(SWITCH_ACCOUNT_DELAY_MS)} delay...`); + await sleep(SWITCH_ACCOUNT_DELAY_MS); + accountManager.markRateLimited(account.email, smartBackoffMs, model); + throw new Error(`QUOTA_EXHAUSTED: ${errorText}`); + } else { + // Short-term rate limit but not first attempt - use exponential backoff delay + const waitMs = backoff.delayMs; + // markRateLimited already increments consecutiveFailures internally + accountManager.markRateLimited(account.email, waitMs, model); + logger.info(`[CloudCode] Rate limit on ${account.email} (attempt ${backoff.attempt}), waiting ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + } + + if (response.status >= 400) { + // Check for 503/529 MODEL_CAPACITY_EXHAUSTED - use progressive backoff like 429 capacity + // 529 = Site Overloaded (same treatment as 503) + if ((response.status === 503 || response.status === 529) && isModelCapacityExhausted(errorText)) { + if (capacityRetryCount < MAX_CAPACITY_RETRIES) { + // Progressive capacity backoff tiers (same as 429 capacity handling) + const tierIndex = Math.min(capacityRetryCount, CAPACITY_BACKOFF_TIERS_MS.length - 1); + const waitMs = CAPACITY_BACKOFF_TIERS_MS[tierIndex]; + capacityRetryCount++; + accountManager.incrementConsecutiveFailures(account.email); + logger.info(`[CloudCode] ${response.status} Model capacity exhausted, retry ${capacityRetryCount}/${MAX_CAPACITY_RETRIES} after ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + // Max capacity retries exceeded - switch account + logger.warn(`[CloudCode] Max capacity retries (${MAX_CAPACITY_RETRIES}) exceeded on ${response.status}, switching account`); + accountManager.markRateLimited(account.email, BACKOFF_BY_ERROR_TYPE.MODEL_CAPACITY_EXHAUSTED, model); + throw new Error(`CAPACITY_EXHAUSTED: ${errorText}`); + } + + // 400 errors are client errors - fail immediately, don't retry or switch accounts + // Examples: token limit exceeded, invalid schema, malformed request + if (response.status === 400) { + logger.error(`[CloudCode] Invalid request (400): ${errorText.substring(0, 200)}`); + throw new Error(`invalid_request_error: ${errorText}`); + } + + // 403 with VALIDATION_REQUIRED or PERMISSION_DENIED is an account-level error + // The account needs validation (captcha, terms, etc.) - trying different endpoints won't help + // Mark account as invalid (requires user intervention) and rotate (fixes #248) + if (response.status === 403 && isValidationRequired(errorText)) { + const verifyUrl = extractVerificationUrl(errorText); + logger.warn(`[CloudCode] 403 VALIDATION_REQUIRED/PERMISSION_DENIED for ${account.email}, marking invalid and rotating account...`); + accountManager.markInvalid(account.email, 'Account requires verification', verifyUrl); + throw new AccountForbiddenError(errorText, account.email); + } + + // 403 with permanent ToS ban — account is permanently disabled by Google + // Unlike VALIDATION_REQUIRED, this cannot be resolved by verification + if (response.status === 403 && isAccountBanned(errorText)) { + logger.warn(`[CloudCode] 403 ToS BANNED for ${account.email}, marking invalid permanently...`); + accountManager.markInvalid(account.email, 'Account banned — Gemini disabled for Terms of Service violation'); + throw new AccountForbiddenError(errorText, account.email); + } + + lastError = new Error(`API error ${response.status}: ${errorText}`); + // Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior) + if (response.status === 403 || response.status === 404) { + logger.warn(`[CloudCode] ${response.status} at ${endpoint}...`); + } else if (response.status >= 500) { + logger.warn(`[CloudCode] ${response.status} error, waiting 1s before retry...`); + await sleep(1000); + } + endpointIndex++; + continue; + } + } + + // For thinking models, parse SSE and accumulate all parts + if (isThinking) { + const result = await parseThinkingSSEResponse(response, anthropicRequest.model); + // Clear rate limit state on success + clearRateLimitState(account.email, model); + accountManager.notifySuccess(account, model); + return result; + } + + // Non-thinking models use regular JSON + const data = await response.json(); + logger.debug('[CloudCode] Response received'); + // Clear rate limit state on success + clearRateLimitState(account.email, model); + accountManager.notifySuccess(account, model); + return convertGoogleToAnthropic(data, anthropicRequest.model); + + } catch (endpointError) { + if (isRateLimitError(endpointError)) { + throw endpointError; // Re-throw to trigger account switch + } + // 403 account-level errors - re-throw to trigger account rotation + if (isAccountForbiddenError(endpointError)) { + throw endpointError; + } + // 400 errors are client errors - re-throw immediately, don't retry + if (endpointError.message?.includes('400')) { + throw endpointError; + } + logger.warn(`[CloudCode] Error at ${endpoint}:`, endpointError.message); + lastError = endpointError; + endpointIndex++; + } + } + + // If all endpoints failed for this account + if (lastError) { + if (lastError.is429) { + logger.warn(`[CloudCode] All endpoints rate-limited for ${account.email}`); + accountManager.markRateLimited(account.email, lastError.resetMs, model); + throw new Error(`Rate limited: ${lastError.errorText}`); + } + throw lastError; + } + + } catch (error) { + if (isRateLimitError(error)) { + // Rate limited - already marked, notify strategy and continue to next account + accountManager.notifyRateLimit(account, model); + logger.info(`[CloudCode] Account ${account.email} rate-limited, trying next...`); + + // CRITICAL FIX: If this is a duplicate rate limit (account was already known to be + // rate-limited), don't count it as a failed attempt. This prevents "Max retries exceeded" + // when thundering herd causes all accounts to be rate-limited and we're just cycling + // through known-bad accounts waiting for rate limits to expire. + if (error.message?.includes('RATE_LIMITED_DEDUP')) { + attempt--; + } + continue; + } + if (isAuthError(error)) { + // Auth invalid - already marked, continue to next account + logger.warn(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`); + continue; + } + if (isAccountForbiddenError(error)) { + // 403 VALIDATION_REQUIRED / PERMISSION_DENIED - account-level error + // Already marked with cooldown, notify strategy and rotate to next account + accountManager.notifyFailure(account, model); + logger.warn(`[CloudCode] Account ${account.email} forbidden (403 VALIDATION_REQUIRED), trying next...`); + continue; + } + // Handle 5xx errors + if (error.message.includes('API error 5') || error.message.includes('500') || error.message.includes('503')) { + accountManager.notifyFailure(account, model); + + // Track 5xx errors for extended cooldown + // Note: markRateLimited already increments consecutiveFailures internally + const currentFailures = accountManager.getConsecutiveFailures(account.email); + if (currentFailures + 1 >= MAX_CONSECUTIVE_FAILURES) { + logger.warn(`[CloudCode] Account ${account.email} has ${currentFailures + 1} consecutive failures, applying extended cooldown (${formatDuration(EXTENDED_COOLDOWN_MS)})`); + accountManager.markRateLimited(account.email, EXTENDED_COOLDOWN_MS, model); + } else { + accountManager.incrementConsecutiveFailures(account.email); + logger.warn(`[CloudCode] Account ${account.email} failed with 5xx error (${currentFailures + 1}/${MAX_CONSECUTIVE_FAILURES}), trying next...`); + } + continue; + } + + if (isNetworkError(error)) { + accountManager.notifyFailure(account, model); + + // Track network errors for extended cooldown + // Note: markRateLimited already increments consecutiveFailures internally + const currentFailures = accountManager.getConsecutiveFailures(account.email); + if (currentFailures + 1 >= MAX_CONSECUTIVE_FAILURES) { + logger.warn(`[CloudCode] Account ${account.email} has ${currentFailures + 1} consecutive network failures, applying extended cooldown (${formatDuration(EXTENDED_COOLDOWN_MS)})`); + accountManager.markRateLimited(account.email, EXTENDED_COOLDOWN_MS, model); + } else { + accountManager.incrementConsecutiveFailures(account.email); + logger.warn(`[CloudCode] Network error for ${account.email} (${currentFailures + 1}/${MAX_CONSECUTIVE_FAILURES}), trying next account... (${error.message})`); + } + await sleep(1000); + continue; + } + + throw error; + } + } + + // All retries exhausted - try fallback model if enabled + if (fallbackEnabled) { + const fallbackModel = getFallbackModel(model); + if (fallbackModel) { + logger.warn(`[CloudCode] All retries exhausted for ${model}. Attempting fallback to ${fallbackModel}`); + const fallbackRequest = { ...anthropicRequest, model: fallbackModel }; + return await sendMessage(fallbackRequest, accountManager, false); + } + } + + throw new Error('Max retries exceeded'); +} diff --git a/src/cloudcode/model-api.js b/src/cloudcode/model-api.js new file mode 100755 index 0000000000000000000000000000000000000000..1cd618c26938a94bf4bd393b681818e672335ccd --- /dev/null +++ b/src/cloudcode/model-api.js @@ -0,0 +1,341 @@ +/** + * Model API for Cloud Code + * + * Handles model listing and quota retrieval from the Cloud Code API. + */ + +import { + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_HEADERS, + LOAD_CODE_ASSIST_ENDPOINTS, + LOAD_CODE_ASSIST_HEADERS, + CLIENT_METADATA, + getModelFamily, + MODEL_VALIDATION_CACHE_TTL_MS +} from '../constants.js'; +import { logger } from '../utils/logger.js'; +import { throttledFetch } from '../utils/helpers.js'; + +// Model validation cache +const modelCache = { + validModels: new Set(), + lastFetched: 0, + fetchPromise: null // Prevents concurrent fetches +}; + +/** + * Check if a model is supported (Claude or Gemini) + * @param {string} modelId - Model ID to check + * @returns {boolean} True if model is supported + */ +function isSupportedModel(modelId) { + const family = getModelFamily(modelId); + return family === 'claude' || family === 'gemini'; +} + +/** + * List available models in Anthropic API format + * Fetches models dynamically from the Cloud Code API + * + * @param {string} token - OAuth access token + * @returns {Promise<{object: string, data: Array<{id: string, object: string, created: number, owned_by: string, description: string}>}>} List of available models + */ +export async function listModels(token) { + const data = await fetchAvailableModels(token); + if (!data || !data.models) { + return { object: 'list', data: [] }; + } + + const modelList = Object.entries(data.models) + .filter(([modelId]) => isSupportedModel(modelId)) + .map(([modelId, modelData]) => ({ + id: modelId, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'anthropic', + description: modelData.displayName || modelId + })); + + // Warm the model validation cache + modelCache.validModels = new Set(modelList.map(m => m.id)); + modelCache.lastFetched = Date.now(); + + return { + object: 'list', + data: modelList + }; +} + +/** + * Fetch available models with quota info from Cloud Code API + * Returns model quotas including remaining fraction and reset time + * + * @param {string} token - OAuth access token + * @param {string} [projectId] - Optional project ID for accurate quota info + * @returns {Promise} Raw response from fetchAvailableModels API + */ +export async function fetchAvailableModels(token, projectId = null) { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }; + + // Include project ID in body for accurate quota info (per Quotio implementation) + const body = projectId ? { project: projectId } : {}; + + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const url = `${endpoint}/v1internal:fetchAvailableModels`; + const response = await throttledFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.warn(`[CloudCode] fetchAvailableModels error at ${endpoint}: ${response.status}`); + // Detect permanent ToS ban — no point trying other endpoints + if (response.status === 403) { + const lower = (errorText || '').toLowerCase(); + if (lower.includes('has been disabled') && lower.includes('violation of terms of service')) { + throw new Error(`ACCOUNT_BANNED: ${errorText}`); + } + } + continue; + } + + return await response.json(); + } catch (error) { + logger.warn(`[CloudCode] fetchAvailableModels failed at ${endpoint}:`, error.message); + } + } + + throw new Error('Failed to fetch available models from all endpoints'); +} + +/** + * Get model quotas for an account + * Extracts quota info (remaining fraction and reset time) for each model + * + * @param {string} token - OAuth access token + * @param {string} [projectId] - Optional project ID for accurate quota info + * @returns {Promise} Map of modelId -> { remainingFraction, resetTime } + */ +export async function getModelQuotas(token, projectId = null) { + const data = await fetchAvailableModels(token, projectId); + if (!data || !data.models) return {}; + + const quotas = {}; + for (const [modelId, modelData] of Object.entries(data.models)) { + // Only include Claude and Gemini models + if (!isSupportedModel(modelId)) continue; + + if (modelData.quotaInfo) { + quotas[modelId] = { + // When remainingFraction is missing but resetTime is present, quota is exhausted (0%) + remainingFraction: modelData.quotaInfo.remainingFraction ?? (modelData.quotaInfo.resetTime ? 0 : null), + resetTime: modelData.quotaInfo.resetTime ?? null + }; + } + } + + return quotas; +} + +/** + * Parse tier ID string to determine subscription level + * @param {string} tierId - The tier ID from the API + * @returns {'free' | 'pro' | 'ultra' | 'unknown'} The subscription tier + */ +export function parseTierId(tierId) { + if (!tierId) return 'unknown'; + const lower = tierId.toLowerCase(); + + if (lower.includes('ultra')) { + return 'ultra'; + } + if (lower === 'standard-tier') { + // standard-tier = "Gemini Code Assist" (paid, project-based) + return 'pro'; + } + if (lower.includes('pro') || lower.includes('premium')) { + return 'pro'; + } + if (lower === 'free-tier' || lower.includes('free')) { + return 'free'; + } + return 'unknown'; +} + +/** + * Get subscription tier for an account + * Calls loadCodeAssist API to discover project ID and subscription tier + * + * @param {string} token - OAuth access token + * @returns {Promise<{tier: string, projectId: string|null}>} Subscription tier (free/pro/ultra) and project ID + */ +export async function getSubscriptionTier(token) { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...LOAD_CODE_ASSIST_HEADERS + }; + + for (const endpoint of LOAD_CODE_ASSIST_ENDPOINTS) { + try { + const url = `${endpoint}/v1internal:loadCodeAssist`; + const response = await throttledFetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + metadata: CLIENT_METADATA, + mode: 1 + }) + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`); + // Detect permanent ToS ban — no point trying other endpoints + if (response.status === 403) { + const lower = (errorText || '').toLowerCase(); + if (lower.includes('has been disabled') && lower.includes('violation of terms of service')) { + throw new Error(`ACCOUNT_BANNED: ${errorText}`); + } + } + continue; + } + + const data = await response.json(); + + // Debug: Log all tier-related fields from the response + logger.debug(`[CloudCode] loadCodeAssist tier data: paidTier=${JSON.stringify(data.paidTier)}, currentTier=${JSON.stringify(data.currentTier)}, allowedTiers=${JSON.stringify(data.allowedTiers?.map(t => ({ id: t?.id, isDefault: t?.isDefault })))}`); + + // Extract project ID + let projectId = null; + if (typeof data.cloudaicompanionProject === 'string') { + projectId = data.cloudaicompanionProject; + } else if (data.cloudaicompanionProject?.id) { + projectId = data.cloudaicompanionProject.id; + } + + // Extract subscription tier + // Priority: paidTier > currentTier > allowedTiers + // - paidTier.id: "g1-pro-tier", "g1-ultra-tier" (Google One subscription) + // - currentTier.id: "standard-tier" (pro), "free-tier" (free) + // - allowedTiers: fallback when currentTier is missing + // Note: paidTier is sometimes missing from the response even for Pro accounts + let tier = 'unknown'; + let tierId = null; + let tierSource = null; + + // 1. Check paidTier first (Google One AI subscription - most reliable) + if (data.paidTier?.id) { + tierId = data.paidTier.id; + tier = parseTierId(tierId); + tierSource = 'paidTier'; + } + + // 2. Fall back to currentTier if paidTier didn't give us a tier + if (tier === 'unknown' && data.currentTier?.id) { + tierId = data.currentTier.id; + tier = parseTierId(tierId); + tierSource = 'currentTier'; + } + + // 3. Fall back to allowedTiers (find the default or first non-free tier) + if (tier === 'unknown' && Array.isArray(data.allowedTiers) && data.allowedTiers.length > 0) { + // First look for the default tier + let defaultTier = data.allowedTiers.find(t => t?.isDefault); + if (!defaultTier) { + defaultTier = data.allowedTiers[0]; + } + if (defaultTier?.id) { + tierId = defaultTier.id; + tier = parseTierId(tierId); + tierSource = 'allowedTiers'; + } + } + + logger.debug(`[CloudCode] Subscription detected: ${tier} (tierId: ${tierId}, source: ${tierSource}), Project: ${projectId}`); + + return { tier, projectId }; + } catch (error) { + logger.warn(`[CloudCode] loadCodeAssist failed at ${endpoint}:`, error.message); + } + } + + // Fallback: return default values if all endpoints fail + logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.'); + return { tier: 'free', projectId: null }; +} + +/** + * Populate the model validation cache + * @param {string} token - OAuth access token + * @param {string} [projectId] - Optional project ID + * @returns {Promise} + */ +async function populateModelCache(token, projectId = null) { + const now = Date.now(); + + // Check if cache is fresh + if (modelCache.validModels.size > 0 && (now - modelCache.lastFetched) < MODEL_VALIDATION_CACHE_TTL_MS) { + return; + } + + // If already fetching, wait for it + if (modelCache.fetchPromise) { + await modelCache.fetchPromise; + return; + } + + // Start fetch + modelCache.fetchPromise = (async () => { + try { + const data = await fetchAvailableModels(token, projectId); + if (data && data.models) { + const validIds = Object.keys(data.models).filter(modelId => isSupportedModel(modelId)); + modelCache.validModels = new Set(validIds); + modelCache.lastFetched = Date.now(); + logger.debug(`[CloudCode] Model cache populated with ${validIds.length} models`); + } + } catch (error) { + logger.warn(`[CloudCode] Failed to populate model cache: ${error.message}`); + // Don't throw - validation should degrade gracefully + } finally { + modelCache.fetchPromise = null; + } + })(); + + await modelCache.fetchPromise; +} + +/** + * Check if a model ID is valid (exists in the available models list) + * Uses a cached model list with TTL-based refresh + * @param {string} modelId - Model ID to validate + * @param {string} token - OAuth access token for cache population + * @param {string} [projectId] - Optional project ID + * @returns {Promise} True if model is valid + */ +export async function isValidModel(modelId, token, projectId = null) { + try { + // Populate cache if needed + await populateModelCache(token, projectId); + + // If cache is populated, validate against it + if (modelCache.validModels.size > 0) { + return modelCache.validModels.has(modelId); + } + + // Cache empty (fetch failed) - fail open, let API validate + return true; + } catch (error) { + logger.debug(`[CloudCode] Model validation error: ${error.message}`); + // Fail open - let the API validate + return true; + } +} diff --git a/src/cloudcode/rate-limit-parser.js b/src/cloudcode/rate-limit-parser.js new file mode 100755 index 0000000000000000000000000000000000000000..971c03b29911fcab45d4ded9c8a5a55089fd4710 --- /dev/null +++ b/src/cloudcode/rate-limit-parser.js @@ -0,0 +1,242 @@ +/** + * Rate Limit Parser for Cloud Code + * + * Parses reset times from HTTP headers and error messages. + * Supports various formats: Retry-After, x-ratelimit-reset, + * quotaResetDelay, quotaResetTimeStamp, and duration strings. + */ + +import { formatDuration } from '../utils/helpers.js'; +import { logger } from '../utils/logger.js'; + +/** + * Parse reset time from HTTP response or error + * Checks headers first, then error message body + * Returns milliseconds or null if not found + * + * @param {Response|Error} responseOrError - HTTP Response object or Error + * @param {string} errorText - Optional error body text + */ +export function parseResetTime(responseOrError, errorText = '') { + let resetMs = null; + + // If it's a Response object, check headers first + if (responseOrError && typeof responseOrError.headers?.get === 'function') { + const headers = responseOrError.headers; + + // Standard Retry-After header (seconds or HTTP date) + const retryAfter = headers.get('retry-after'); + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds)) { + resetMs = seconds * 1000; + logger.debug(`[CloudCode] Retry-After header: ${seconds}s`); + } else { + // Try parsing as HTTP date + const date = new Date(retryAfter); + if (!isNaN(date.getTime())) { + resetMs = date.getTime() - Date.now(); + if (resetMs > 0) { + logger.debug(`[CloudCode] Retry-After date: ${retryAfter}`); + } else { + resetMs = null; + } + } + } + } + + // x-ratelimit-reset (Unix timestamp in seconds) + if (!resetMs) { + const ratelimitReset = headers.get('x-ratelimit-reset'); + if (ratelimitReset) { + const resetTimestamp = parseInt(ratelimitReset, 10) * 1000; + resetMs = resetTimestamp - Date.now(); + if (resetMs > 0) { + logger.debug(`[CloudCode] x-ratelimit-reset: ${new Date(resetTimestamp).toISOString()}`); + } else { + resetMs = null; + } + } + } + + // x-ratelimit-reset-after (seconds) + if (!resetMs) { + const resetAfter = headers.get('x-ratelimit-reset-after'); + if (resetAfter) { + const seconds = parseInt(resetAfter, 10); + if (!isNaN(seconds) && seconds > 0) { + resetMs = seconds * 1000; + logger.debug(`[CloudCode] x-ratelimit-reset-after: ${seconds}s`); + } + } + } + } + + // If no header found, try parsing from error message/body + if (!resetMs) { + const msg = (responseOrError instanceof Error ? responseOrError.message : errorText) || ''; + + // Try to extract "quotaResetDelay" first (e.g. "754.431528ms" or "1.5s") + // This is Google's preferred format for rate limit reset delay + const quotaDelayMatch = msg.match(/quotaResetDelay[:\s"]+(\d+(?:\.\d+)?)(ms|s)/i); + if (quotaDelayMatch) { + const value = parseFloat(quotaDelayMatch[1]); + const unit = quotaDelayMatch[2].toLowerCase(); + resetMs = unit === 's' ? Math.ceil(value * 1000) : Math.ceil(value); + logger.debug(`[CloudCode] Parsed quotaResetDelay from body: ${resetMs}ms`); + } + + // Try to extract "quotaResetTimeStamp" (ISO format like "2025-12-31T07:00:47Z") + if (!resetMs) { + const quotaTimestampMatch = msg.match(/quotaResetTimeStamp[:\s"]+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i); + if (quotaTimestampMatch) { + const resetTime = new Date(quotaTimestampMatch[1]).getTime(); + if (!isNaN(resetTime)) { + resetMs = resetTime - Date.now(); + // Even if expired or 0, we found a timestamp, so rely on it. + // But if it's negative, it means "now", so treat as small wait. + logger.debug(`[CloudCode] Parsed quotaResetTimeStamp: ${quotaTimestampMatch[1]} (Delta: ${resetMs}ms)`); + } + } + } + + // Try to extract "retry-after-ms" or "retryDelay" - check seconds format first (e.g. "7739.23s") + // Added stricter regex to avoid partial matches + if (!resetMs) { + const secMatch = msg.match(/(?:retry[-_]?after[-_]?ms|retryDelay)[:\s"]+([\d.]+)(?:s\b|s")/i); + if (secMatch) { + resetMs = Math.ceil(parseFloat(secMatch[1]) * 1000); + logger.debug(`[CloudCode] Parsed retry seconds from body (precise): ${resetMs}ms`); + } + } + + if (!resetMs) { + // Check for ms (explicit "ms" suffix or implicit if no suffix) + const msMatch = msg.match(/(?:retry[-_]?after[-_]?ms|retryDelay)[:\s"]+(\d+)(?:\s*ms)?(?![\w.])/i); + if (msMatch) { + resetMs = parseInt(msMatch[1], 10); + logger.debug(`[CloudCode] Parsed retry-after-ms from body: ${resetMs}ms`); + } + } + + // Try to extract seconds value like "retry after 60 seconds" + if (!resetMs) { + const secMatch = msg.match(/retry\s+(?:after\s+)?(\d+)\s*(?:sec|s\b)/i); + if (secMatch) { + resetMs = parseInt(secMatch[1], 10) * 1000; + logger.debug(`[CloudCode] Parsed retry seconds from body: ${secMatch[1]}s`); + } + } + + // Try to extract duration like "1h23m45s" or "23m45s" or "45s" + if (!resetMs) { + const durationMatch = msg.match(/(\d+)h(\d+)m(\d+)s|(\d+)m(\d+)s|(\d+)s/i); + if (durationMatch) { + if (durationMatch[1]) { + const hours = parseInt(durationMatch[1], 10); + const minutes = parseInt(durationMatch[2], 10); + const seconds = parseInt(durationMatch[3], 10); + resetMs = (hours * 3600 + minutes * 60 + seconds) * 1000; + } else if (durationMatch[4]) { + const minutes = parseInt(durationMatch[4], 10); + const seconds = parseInt(durationMatch[5], 10); + resetMs = (minutes * 60 + seconds) * 1000; + } else if (durationMatch[6]) { + resetMs = parseInt(durationMatch[6], 10) * 1000; + } + if (resetMs) { + logger.debug(`[CloudCode] Parsed duration from body: ${formatDuration(resetMs)}`); + } + } + } + + // Try to extract ISO timestamp or Unix timestamp + if (!resetMs) { + const isoMatch = msg.match(/reset[:\s"]+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i); + if (isoMatch) { + const resetTime = new Date(isoMatch[1]).getTime(); + if (!isNaN(resetTime)) { + resetMs = resetTime - Date.now(); + if (resetMs > 0) { + logger.debug(`[CloudCode] Parsed ISO reset time: ${isoMatch[1]}`); + } else { + resetMs = null; + } + } + } + } + } + + // SANITY CHECK: Handle very small or negative reset times + // For sub-second rate limits (common with per-second quotas), add a small buffer + // For negative or zero, use a reasonable minimum + if (resetMs !== null) { + if (resetMs <= 0) { + logger.debug(`[CloudCode] Reset time invalid (${resetMs}ms), using 500ms default`); + resetMs = 500; + } else if (resetMs < 500) { + // Very short reset - add 200ms buffer for network latency + logger.debug(`[CloudCode] Short reset time (${resetMs}ms), adding 200ms buffer`); + resetMs = resetMs + 200; + } + // Note: No longer enforcing 2s minimum - this was causing cascading failures + // when all accounts had short rate limits simultaneously + } + + return resetMs; +} + +/** + * Parse the rate limit reason from error text + * Used for smart backoff by error type (matches opencode-antigravity-auth) + * + * @param {string} errorText - Error message/body text + * @param {number} [status] - HTTP status code (optional, for status-based classification) + * @returns {'RATE_LIMIT_EXCEEDED' | 'QUOTA_EXHAUSTED' | 'MODEL_CAPACITY_EXHAUSTED' | 'SERVER_ERROR' | 'UNKNOWN'} Error reason + */ +export function parseRateLimitReason(errorText, status) { + // Status code checks FIRST (matches opencode-antigravity-auth Rust parity) + // 529 = Site Overloaded, 503 = Service Unavailable → Capacity issues + if (status === 529 || status === 503) return 'MODEL_CAPACITY_EXHAUSTED'; + // 500 = Internal Server Error → Treat as Server Error (soft wait) + if (status === 500) return 'SERVER_ERROR'; + + const lower = (errorText || '').toLowerCase(); + + // Check for quota exhaustion (daily/hourly limits) + if (lower.includes('quota_exhausted') || + lower.includes('quotaresetdelay') || + lower.includes('quotaresettimestamp') || + lower.includes('resource_exhausted') || + lower.includes('daily limit') || + lower.includes('quota exceeded')) { + return 'QUOTA_EXHAUSTED'; + } + + // Check for model capacity issues (temporary, retry quickly) + if (lower.includes('model_capacity_exhausted') || + lower.includes('capacity_exhausted') || + lower.includes('model is currently overloaded') || + lower.includes('service temporarily unavailable')) { + return 'MODEL_CAPACITY_EXHAUSTED'; + } + + // Check for rate limiting (per-minute limits) + if (lower.includes('rate_limit_exceeded') || + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('throttl')) { + return 'RATE_LIMIT_EXCEEDED'; + } + + // Check for server errors + if (lower.includes('internal server error') || + lower.includes('server error') || + lower.includes('503') || + lower.includes('502') || + lower.includes('504')) { + return 'SERVER_ERROR'; + } + + return 'UNKNOWN'; +} diff --git a/src/cloudcode/rate-limit-state.js b/src/cloudcode/rate-limit-state.js new file mode 100755 index 0000000000000000000000000000000000000000..e60be96f9ec71765a7504b1cb13417f4ec5093f1 --- /dev/null +++ b/src/cloudcode/rate-limit-state.js @@ -0,0 +1,208 @@ +/** + * Rate Limit State Management + * + * Shared utilities for rate limit tracking, backoff calculation, + * and error classification. Used by both streaming and non-streaming handlers. + */ + +import { + RATE_LIMIT_DEDUP_WINDOW_MS, + RATE_LIMIT_STATE_RESET_MS, + FIRST_RETRY_DELAY_MS, + BACKOFF_BY_ERROR_TYPE, + QUOTA_EXHAUSTED_BACKOFF_TIERS_MS, + MIN_BACKOFF_MS, + CAPACITY_JITTER_MAX_MS +} from '../constants.js'; +import { generateJitter } from '../utils/helpers.js'; +import { logger } from '../utils/logger.js'; +import { parseRateLimitReason } from './rate-limit-parser.js'; + +/** + * Rate limit deduplication - prevents thundering herd on concurrent rate limits. + * Tracks rate limit state per account+model including consecutive429 count and timestamps. + * + * This is a singleton Map shared across all handlers (streaming and non-streaming). + */ +const rateLimitStateByAccountModel = new Map(); // `${email}:${model}` -> { consecutive429, lastAt } + +/** + * Get deduplication key for rate limit tracking + * @param {string} email - Account email + * @param {string} model - Model ID + * @returns {string} Dedup key + */ +export function getDedupKey(email, model) { + return `${email}:${model}`; +} + +/** + * Get rate limit backoff with deduplication and exponential backoff (matches opencode-antigravity-auth) + * @param {string} email - Account email + * @param {string} model - Model ID + * @param {number|null} serverRetryAfterMs - Server-provided retry time + * @returns {{attempt: number, delayMs: number, isDuplicate: boolean}} Backoff info + */ +export function getRateLimitBackoff(email, model, serverRetryAfterMs) { + const now = Date.now(); + const stateKey = getDedupKey(email, model); + const previous = rateLimitStateByAccountModel.get(stateKey); + + // Check if within dedup window - return duplicate status + if (previous && (now - previous.lastAt < RATE_LIMIT_DEDUP_WINDOW_MS)) { + const baseDelay = serverRetryAfterMs ?? FIRST_RETRY_DELAY_MS; + const backoffDelay = Math.min(baseDelay * Math.pow(2, previous.consecutive429 - 1), 60000); + logger.debug(`[CloudCode] Rate limit on ${email}:${model} within dedup window, attempt=${previous.consecutive429}, isDuplicate=true`); + return { attempt: previous.consecutive429, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: true }; + } + + // Determine attempt number - reset after RATE_LIMIT_STATE_RESET_MS of inactivity + const attempt = previous && (now - previous.lastAt < RATE_LIMIT_STATE_RESET_MS) + ? previous.consecutive429 + 1 + : 1; + + // Update state + rateLimitStateByAccountModel.set(stateKey, { consecutive429: attempt, lastAt: now }); + + // Calculate exponential backoff + const baseDelay = serverRetryAfterMs ?? FIRST_RETRY_DELAY_MS; + const backoffDelay = Math.min(baseDelay * Math.pow(2, attempt - 1), 60000); + + logger.debug(`[CloudCode] Rate limit backoff for ${email}:${model}: attempt=${attempt}, delayMs=${Math.max(baseDelay, backoffDelay)}`); + return { attempt, delayMs: Math.max(baseDelay, backoffDelay), isDuplicate: false }; +} + +/** + * Clear rate limit state after successful request + * @param {string} email - Account email + * @param {string} model - Model ID + */ +export function clearRateLimitState(email, model) { + const key = getDedupKey(email, model); + rateLimitStateByAccountModel.delete(key); +} + +/** + * Detect permanent authentication failures that require re-authentication. + * These should mark the account as invalid rather than just clearing cache. + * @param {string} errorText - Error message from API + * @returns {boolean} True if permanent auth failure + */ +export function isPermanentAuthFailure(errorText) { + const lower = (errorText || '').toLowerCase(); + return lower.includes('invalid_grant') || + lower.includes('token revoked') || + lower.includes('token has been expired or revoked') || + lower.includes('token_revoked') || + lower.includes('invalid_client') || + lower.includes('credentials are invalid'); +} + +/** + * Detect if 403 error is due to VALIDATION_REQUIRED or PERMISSION_DENIED. + * These are account-level errors that should trigger account rotation, + * not just endpoint rotation. The account needs validation (e.g., captcha, + * terms acceptance) which cannot be resolved by trying different endpoints. + * @param {string} errorText - Error message from API + * @returns {boolean} True if validation/permission error requiring account rotation + */ +export function isValidationRequired(errorText) { + const lower = (errorText || '').toLowerCase(); + return lower.includes('validation_required') || + lower.includes('account_disabled') || + lower.includes('user_disabled'); +} + +/** + * Extract the Google verification URL from an error message. + * The 403 VALIDATION_REQUIRED error contains a URL the user must visit. + * @param {string} errorText - Error message from the API + * @returns {string|null} The verification URL, or null if not found + */ +export function extractVerificationUrl(errorText) { + if (!errorText) return null; + // Try structured JSON first — the 403 response often has details[].metadata.validation_url + try { + const parsed = JSON.parse(errorText); + const details = parsed?.error?.details || []; + for (const detail of details) { + if (detail?.metadata?.validation_url) { + return detail.metadata.validation_url; + } + } + } catch { + // Not valid JSON or no structured field — fall through to regex + } + // Fallback: regex match for verification URL in unstructured text + const raw = errorText.match(/https:\/\/accounts\.google\.com\/signin\/continue\?[^\s"\\]+/); + if (!raw) return null; + return raw[0].replace(/[,.)}>\]]+$/, ''); +} + +/** + * Detect if 403 error is due to a permanent account ban (ToS violation). + * These accounts are permanently disabled by Google and cannot be recovered + * by retrying or re-authenticating. User must contact Google support to appeal. + * @param {string} errorText - Error message from API + * @returns {boolean} True if account is permanently banned + */ +export function isAccountBanned(errorText) { + const lower = (errorText || '').toLowerCase(); + return lower.includes('has been disabled') && lower.includes('violation of terms of service'); +} + +/** + * Detect if 429 error is due to model capacity (not user quota). + * Capacity issues should retry on same account with shorter delay. + * @param {string} errorText - Error message from API + * @returns {boolean} True if capacity exhausted (not quota) + */ +export function isModelCapacityExhausted(errorText) { + const lower = (errorText || '').toLowerCase(); + return lower.includes('model_capacity_exhausted') || + lower.includes('capacity_exhausted') || + lower.includes('model is currently overloaded') || + lower.includes('service temporarily unavailable'); +} + +/** + * Calculate smart backoff based on error type (matches opencode-antigravity-auth) + * @param {string} errorText - Error message + * @param {number|null} serverResetMs - Reset time from server + * @param {number} consecutiveFailures - Number of consecutive failures + * @returns {number} Backoff time in milliseconds + */ +export function calculateSmartBackoff(errorText, serverResetMs, consecutiveFailures = 0) { + // If server provides a reset time, use it (with minimum floor to prevent loops) + if (serverResetMs && serverResetMs > 0) { + return Math.max(serverResetMs, MIN_BACKOFF_MS); + } + + const reason = parseRateLimitReason(errorText); + + switch (reason) { + case 'QUOTA_EXHAUSTED': + // Progressive backoff: [60s, 5m, 30m, 2h] + const tierIndex = Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFF_TIERS_MS.length - 1); + return QUOTA_EXHAUSTED_BACKOFF_TIERS_MS[tierIndex]; + case 'RATE_LIMIT_EXCEEDED': + return BACKOFF_BY_ERROR_TYPE.RATE_LIMIT_EXCEEDED; + case 'MODEL_CAPACITY_EXHAUSTED': + // Apply jitter to prevent thundering herd - clients retry at staggered times + return BACKOFF_BY_ERROR_TYPE.MODEL_CAPACITY_EXHAUSTED + generateJitter(CAPACITY_JITTER_MAX_MS); + case 'SERVER_ERROR': + return BACKOFF_BY_ERROR_TYPE.SERVER_ERROR; + default: + return BACKOFF_BY_ERROR_TYPE.UNKNOWN; + } +} + +// Periodically clean up stale rate limit state (every 60 seconds) +setInterval(() => { + const cutoff = Date.now() - RATE_LIMIT_STATE_RESET_MS; + for (const [key, state] of rateLimitStateByAccountModel.entries()) { + if (state.lastAt < cutoff) { + rateLimitStateByAccountModel.delete(key); + } + } +}, 60000); diff --git a/src/cloudcode/request-builder.js b/src/cloudcode/request-builder.js new file mode 100755 index 0000000000000000000000000000000000000000..81051c74a971f581d348b1005712d10a6f9268e5 --- /dev/null +++ b/src/cloudcode/request-builder.js @@ -0,0 +1,103 @@ +/** + * Request Builder for Cloud Code + * + * Builds request payloads and headers for the Cloud Code API. + */ + +import crypto from 'crypto'; +import { + ANTIGRAVITY_HEADERS, + ANTIGRAVITY_SYSTEM_INSTRUCTION, + getModelFamily, + isThinkingModel +} from '../constants.js'; +import { convertAnthropicToGoogle } from '../format/index.js'; +import { applyProHighThinkingFallback, resolveCloudCodeApiModel } from '../format/model-aliases.js'; +import { deriveSessionId } from './session-manager.js'; + +/** + * Build the wrapped request body for Cloud Code API + * + * @param {Object} anthropicRequest - The Anthropic-format request + * @param {string} projectId - The project ID to use + * @param {string} accountEmail - The account email for session ID derivation + * @returns {Object} The Cloud Code API request payload + */ +export function buildCloudCodeRequest(anthropicRequest, projectId, accountEmail) { + const requestedModel = anthropicRequest.model; + const apiModel = resolveCloudCodeApiModel(requestedModel); + const googleRequest = convertAnthropicToGoogle(anthropicRequest); + applyProHighThinkingFallback(googleRequest, requestedModel); + + // Use stable session ID derived from first user message for cache continuity + googleRequest.sessionId = deriveSessionId(anthropicRequest, accountEmail); + + // Build system instruction parts array with [ignore] tags to prevent model from + // identifying as "Antigravity" (fixes GitHub issue #76) + // Reference: CLIProxyAPI, gcli2api, AIClient-2-API all use this approach + const systemParts = [ + { text: ANTIGRAVITY_SYSTEM_INSTRUCTION }, + { text: `Please ignore the following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` } + ]; + + // Append any existing system instructions from the request + if (googleRequest.systemInstruction && googleRequest.systemInstruction.parts) { + for (const part of googleRequest.systemInstruction.parts) { + if (part.text) { + systemParts.push({ text: part.text }); + } + } + } + + const payload = { + project: projectId, + model: apiModel, + request: googleRequest, + userAgent: 'antigravity', + requestType: 'agent', // CLIProxyAPI v6.6.89 compatibility + requestId: 'agent-' + crypto.randomUUID() + }; + + // Inject systemInstruction with role: "user" at the top level (CLIProxyAPI v6.6.89 behavior) + payload.request.systemInstruction = { + role: 'user', + parts: systemParts + }; + + return payload; +} + +/** + * Build headers for Cloud Code API requests + * + * @param {string} token - OAuth access token + * @param {string} model - Model name + * @param {string} accept - Accept header value (default: 'application/json') + * @param {string} [sessionId] - Optional session ID for X-Machine-Session-Id header + * @returns {Object} Headers object + */ +export function buildHeaders(token, model, accept = 'application/json', sessionId) { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }; + + // Add session ID header if provided (matches Antigravity binary behavior) + if (sessionId) { + headers['X-Machine-Session-Id'] = sessionId; + } + + const modelFamily = getModelFamily(model); + + // Add interleaved thinking header only for Claude thinking models + if (modelFamily === 'claude' && isThinkingModel(model)) { + headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14'; + } + + if (accept !== 'application/json') { + headers['Accept'] = accept; + } + + return headers; +} diff --git a/src/cloudcode/session-manager.js b/src/cloudcode/session-manager.js new file mode 100755 index 0000000000000000000000000000000000000000..6d9f2395f016bf6b7428321d343dba63039a9f23 --- /dev/null +++ b/src/cloudcode/session-manager.js @@ -0,0 +1,66 @@ +/** + * Session Management for Cloud Code + * + * Handles session ID derivation for prompt caching continuity. + * Session IDs are derived from the first user message to ensure + * the same conversation uses the same session across turns. + */ + +import crypto from 'crypto'; + + +// Runtime storage for session IDs (per account) +// This mimics the behavior of the binary which generates a session ID at startup +// and keeps it for the process lifetime. +// Key: accountEmail, Value: sessionId +const runtimeSessionStore = new Map(); + +/** + * Get or create a session ID for the given account. + * + * The binary generates a session ID once at startup: `p.sessionID = rs() + Date.now()`. + * Since our proxy is long-running, we simulate this "per-launch" behavior by storing + * a generated ID in memory for each account. + * + * - If the proxy restarts, the ID changes (matching binary/VS Code restart behavior). + * - Within a running proxy instance, the ID is stable for that account. + * - This enables prompt caching while using the EXACT random logic of the binary. + * + * @param {Object} anthropicRequest - The Anthropic-format request (unused for ID generation now) + * @param {string} accountEmail - The account email to scope the session ID + * @returns {string} A stable session ID string matching binary format + */ +export function deriveSessionId(anthropicRequest, accountEmail) { + if (!accountEmail) { + // Fallback for requests without an account (should differ every time) + return generateBinaryStyleId(); + } + + // Check if we already have a session ID for this account in this process run + if (runtimeSessionStore.has(accountEmail)) { + return runtimeSessionStore.get(accountEmail); + } + + // Generate a new ID using the binary's exact logic + const newSessionId = generateBinaryStyleId(); + + // Store it for future requests from this account + runtimeSessionStore.set(accountEmail, newSessionId); + + return newSessionId; +} + +/** + * Generate a Session ID using the binary's exact logic. + * logic: `rs() + Date.now()` where `rs()` is randomUUID + */ +function generateBinaryStyleId() { + return crypto.randomUUID() + Date.now().toString(); +} + +/** + * Clears all session IDs (e.g. useful for testing or explicit reset) + */ +export function clearSessionStore() { + runtimeSessionStore.clear(); +} diff --git a/src/cloudcode/sse-parser.js b/src/cloudcode/sse-parser.js new file mode 100755 index 0000000000000000000000000000000000000000..041baf1e57635b01805fd3a0bd56a87c9dbb1bfb --- /dev/null +++ b/src/cloudcode/sse-parser.js @@ -0,0 +1,121 @@ +/** + * SSE Parser for Cloud Code + * + * Parses SSE responses for non-streaming thinking models. + * Accumulates all parts and returns a single response. + */ + +import { convertGoogleToAnthropic } from '../format/index.js'; +import { logger } from '../utils/logger.js'; + +/** + * Parse SSE response for thinking models and accumulate all parts + * + * @param {Response} response - The HTTP response with SSE body + * @param {string} originalModel - The original model name + * @returns {Promise} Anthropic-format response object + */ +export async function parseThinkingSSEResponse(response, originalModel) { + let accumulatedThinkingText = ''; + let accumulatedThinkingSignature = ''; + let accumulatedText = ''; + const finalParts = []; + let usageMetadata = {}; + let finishReason = 'STOP'; + + const flushThinking = () => { + if (accumulatedThinkingText) { + finalParts.push({ + thought: true, + text: accumulatedThinkingText, + thoughtSignature: accumulatedThinkingSignature + }); + accumulatedThinkingText = ''; + accumulatedThinkingSignature = ''; + } + }; + + const flushText = () => { + if (accumulatedText) { + finalParts.push({ text: accumulatedText }); + accumulatedText = ''; + } + }; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + const jsonText = line.slice(5).trim(); + if (!jsonText) continue; + + try { + const data = JSON.parse(jsonText); + const innerResponse = data.response || data; + + if (innerResponse.usageMetadata) { + usageMetadata = innerResponse.usageMetadata; + } + + const candidates = innerResponse.candidates || []; + const firstCandidate = candidates[0] || {}; + if (firstCandidate.finishReason) { + finishReason = firstCandidate.finishReason; + } + + const parts = firstCandidate.content?.parts || []; + for (const part of parts) { + if (part.thought === true) { + flushText(); + accumulatedThinkingText += (part.text || ''); + if (part.thoughtSignature) { + accumulatedThinkingSignature = part.thoughtSignature; + } + } else if (part.functionCall) { + flushThinking(); + flushText(); + finalParts.push(part); + } else if (part.text !== undefined) { + if (!part.text) continue; + flushThinking(); + accumulatedText += part.text; + } else if (part.inlineData) { + // Handle image content + flushThinking(); + flushText(); + finalParts.push(part); + } + } + } catch (e) { + logger.debug('[CloudCode] SSE parse warning:', e.message, 'Raw:', jsonText.slice(0, 100)); + } + } + } + + flushThinking(); + flushText(); + + const accumulatedResponse = { + candidates: [{ content: { parts: finalParts }, finishReason }], + usageMetadata + }; + + const partTypes = finalParts.map(p => p.thought ? 'thought' : (p.functionCall ? 'functionCall' : (p.inlineData ? 'inlineData' : 'text'))); + logger.debug('[CloudCode] Response received (SSE), part types:', partTypes); + if (finalParts.some(p => p.thought)) { + const thinkingPart = finalParts.find(p => p.thought); + logger.debug('[CloudCode] Thinking signature length:', thinkingPart?.thoughtSignature?.length || 0); + } + + return convertGoogleToAnthropic(accumulatedResponse, originalModel); +} diff --git a/src/cloudcode/sse-streamer.js b/src/cloudcode/sse-streamer.js new file mode 100755 index 0000000000000000000000000000000000000000..7fb640a1baa6d075d3545fffb3e8e364701fa744 --- /dev/null +++ b/src/cloudcode/sse-streamer.js @@ -0,0 +1,293 @@ +/** + * SSE Streamer for Cloud Code + * + * Streams SSE events in real-time, converting Google format to Anthropic format. + * Handles thinking blocks, text blocks, and tool use blocks. + */ + +import crypto from 'crypto'; +import { MIN_SIGNATURE_LENGTH, getModelFamily } from '../constants.js'; +import { EmptyResponseError } from '../errors.js'; +import { cacheSignature, cacheThinkingSignature } from '../format/signature-cache.js'; +import { logger } from '../utils/logger.js'; + +/** + * Stream SSE response and yield Anthropic-format events + * + * @param {Response} response - The HTTP response with SSE body + * @param {string} originalModel - The original model name + * @yields {Object} Anthropic-format SSE events + */ +export async function* streamSSEResponse(response, originalModel) { + const messageId = `msg_${crypto.randomBytes(16).toString('hex')}`; + let hasEmittedStart = false; + let blockIndex = 0; + let currentBlockType = null; + let currentThinkingSignature = ''; + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let stopReason = null; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data:')) continue; + + const jsonText = line.slice(5).trim(); + if (!jsonText) continue; + + try { + const data = JSON.parse(jsonText); + const innerResponse = data.response || data; + + // Extract usage metadata (including cache tokens) + const usage = innerResponse.usageMetadata; + if (usage) { + inputTokens = usage.promptTokenCount || inputTokens; + outputTokens = usage.candidatesTokenCount || outputTokens; + cacheReadTokens = usage.cachedContentTokenCount || cacheReadTokens; + } + + const candidates = innerResponse.candidates || []; + const firstCandidate = candidates[0] || {}; + const content = firstCandidate.content || {}; + const parts = content.parts || []; + + // Emit message_start on first data + // Note: input_tokens = promptTokenCount - cachedContentTokenCount (Antigravity includes cached in total) + if (!hasEmittedStart && parts.length > 0) { + hasEmittedStart = true; + yield { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model: originalModel, + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: inputTokens - cacheReadTokens, + output_tokens: 0, + cache_read_input_tokens: cacheReadTokens, + cache_creation_input_tokens: 0 + } + } + }; + } + + // Process each part + for (const part of parts) { + if (part.thought === true) { + // Handle thinking block + const text = part.text || ''; + const signature = part.thoughtSignature || ''; + + if (currentBlockType !== 'thinking') { + if (currentBlockType !== null) { + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + } + currentBlockType = 'thinking'; + currentThinkingSignature = ''; + yield { + type: 'content_block_start', + index: blockIndex, + content_block: { type: 'thinking', thinking: '' } + }; + } + + if (signature && signature.length >= MIN_SIGNATURE_LENGTH) { + currentThinkingSignature = signature; + // Cache thinking signature with model family for cross-model compatibility + const modelFamily = getModelFamily(originalModel); + cacheThinkingSignature(signature, modelFamily); + } + + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'thinking_delta', thinking: text } + }; + + } else if (part.text !== undefined) { + // Skip empty text parts (but preserve whitespace-only chunks for proper spacing) + if (part.text === '') { + continue; + } + + // Handle regular text + if (currentBlockType !== 'text') { + if (currentBlockType === 'thinking' && currentThinkingSignature) { + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature: currentThinkingSignature } + }; + currentThinkingSignature = ''; + } + if (currentBlockType !== null) { + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + } + currentBlockType = 'text'; + yield { + type: 'content_block_start', + index: blockIndex, + content_block: { type: 'text', text: '' } + }; + } + + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'text_delta', text: part.text } + }; + + } else if (part.functionCall) { + // Handle tool use + // For Gemini 3+, capture thoughtSignature from the functionCall part + // The signature is a sibling to functionCall, not inside it + const functionCallSignature = part.thoughtSignature || ''; + + if (currentBlockType === 'thinking' && currentThinkingSignature) { + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature: currentThinkingSignature } + }; + currentThinkingSignature = ''; + } + if (currentBlockType !== null) { + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + } + currentBlockType = 'tool_use'; + stopReason = 'tool_use'; + + const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`; + + // For Gemini, include the thoughtSignature in the tool_use block + // so it can be sent back in subsequent requests + const toolUseBlock = { + type: 'tool_use', + id: toolId, + name: part.functionCall.name, + input: {} + }; + + // Store the signature in the tool_use block for later retrieval + if (functionCallSignature && functionCallSignature.length >= MIN_SIGNATURE_LENGTH) { + toolUseBlock.thoughtSignature = functionCallSignature; + // Cache for future requests (Claude Code may strip this field) + cacheSignature(toolId, functionCallSignature); + } + + yield { + type: 'content_block_start', + index: blockIndex, + content_block: toolUseBlock + }; + + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(part.functionCall.args || {}) + } + }; + } else if (part.inlineData) { + // Handle image content from Google format + if (currentBlockType === 'thinking' && currentThinkingSignature) { + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature: currentThinkingSignature } + }; + currentThinkingSignature = ''; + } + if (currentBlockType !== null) { + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + } + currentBlockType = 'image'; + + // Emit image block as a complete block + yield { + type: 'content_block_start', + index: blockIndex, + content_block: { + type: 'image', + source: { + type: 'base64', + media_type: part.inlineData.mimeType, + data: part.inlineData.data + } + } + }; + + yield { type: 'content_block_stop', index: blockIndex }; + blockIndex++; + currentBlockType = null; + } + } + + // Check finish reason (only if not already set by tool_use) + if (firstCandidate.finishReason && !stopReason) { + if (firstCandidate.finishReason === 'MAX_TOKENS') { + stopReason = 'max_tokens'; + } else if (firstCandidate.finishReason === 'STOP') { + stopReason = 'end_turn'; + } + } + + } catch (parseError) { + logger.warn('[CloudCode] SSE parse error:', parseError.message); + } + } + } + + // Handle no content received - throw error to trigger retry in streaming-handler + if (!hasEmittedStart) { + logger.warn('[CloudCode] No content parts received, throwing for retry'); + throw new EmptyResponseError('No content parts received from API'); + } else { + // Close any open block + if (currentBlockType !== null) { + if (currentBlockType === 'thinking' && currentThinkingSignature) { + yield { + type: 'content_block_delta', + index: blockIndex, + delta: { type: 'signature_delta', signature: currentThinkingSignature } + }; + } + yield { type: 'content_block_stop', index: blockIndex }; + } + } + + // Emit message_delta and message_stop + yield { + type: 'message_delta', + delta: { stop_reason: stopReason || 'end_turn', stop_sequence: null }, + usage: { + output_tokens: outputTokens, + cache_read_input_tokens: cacheReadTokens, + cache_creation_input_tokens: 0 + } + }; + + yield { type: 'message_stop' }; +} diff --git a/src/cloudcode/streaming-handler.js b/src/cloudcode/streaming-handler.js new file mode 100755 index 0000000000000000000000000000000000000000..54daf863e565313f025c403163209aba19d5fe5a --- /dev/null +++ b/src/cloudcode/streaming-handler.js @@ -0,0 +1,548 @@ +/** + * Streaming Handler for Cloud Code + * + * Handles streaming message requests with multi-account support, + * retry logic, and endpoint failover. + */ + +import { + ANTIGRAVITY_ENDPOINT_FALLBACKS, + MAX_RETRIES, + MAX_EMPTY_RESPONSE_RETRIES, + MAX_WAIT_BEFORE_ERROR_MS, + DEFAULT_COOLDOWN_MS, + SWITCH_ACCOUNT_DELAY_MS, + MAX_CONSECUTIVE_FAILURES, + EXTENDED_COOLDOWN_MS, + CAPACITY_BACKOFF_TIERS_MS, + MAX_CAPACITY_RETRIES, + BACKOFF_BY_ERROR_TYPE +} from '../constants.js'; +import { isRateLimitError, isAuthError, isEmptyResponseError, isAccountForbiddenError, AccountForbiddenError } from '../errors.js'; +import { formatDuration, sleep, isNetworkError, throttledFetch } from '../utils/helpers.js'; +import { logger } from '../utils/logger.js'; +import { parseResetTime } from './rate-limit-parser.js'; +import { buildCloudCodeRequest, buildHeaders } from './request-builder.js'; +import { streamSSEResponse } from './sse-streamer.js'; +import { getFallbackModel } from '../fallback-config.js'; +import { + getRateLimitBackoff, + clearRateLimitState, + isPermanentAuthFailure, + isModelCapacityExhausted, + isValidationRequired, + extractVerificationUrl, + isAccountBanned, + calculateSmartBackoff +} from './rate-limit-state.js'; +import crypto from 'crypto'; + +/** + * Send a streaming request to Cloud Code with multi-account support + * Streams events in real-time as they arrive from the server + * + * @param {Object} anthropicRequest - The Anthropic-format request + * @param {string} anthropicRequest.model - Model name to use + * @param {Array} anthropicRequest.messages - Array of message objects + * @param {number} [anthropicRequest.max_tokens] - Maximum tokens to generate + * @param {Object} [anthropicRequest.thinking] - Thinking configuration + * @param {import('../account-manager/index.js').default} accountManager - The account manager instance + * @yields {Object} Anthropic-format SSE events (message_start, content_block_start, content_block_delta, etc.) + * @throws {Error} If max retries exceeded or no accounts available + */ +export async function* sendMessageStream(anthropicRequest, accountManager, fallbackEnabled = false) { + const model = anthropicRequest.model; + + // Retry loop with account failover + // Ensure we try at least as many times as there are accounts to cycle through everyone + const maxAttempts = Math.max(MAX_RETRIES, accountManager.getAccountCount() + 1); + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // Clear any expired rate limits before picking + accountManager.clearExpiredLimits(); + + // Get available accounts for this model + const availableAccounts = accountManager.getAvailableAccounts(model); + + // If no accounts available, check if we should wait or throw error + if (availableAccounts.length === 0) { + // All accounts invalid? Fail immediately — they need user intervention (WebUI FIX button) + // Invalid accounts won't self-recover, so waiting would be an infinite loop + if (accountManager.isAllAccountsInvalid()) { + const invalidAccounts = accountManager.getInvalidAccounts(); + const reasons = [...new Set(invalidAccounts.map(a => a.invalidReason).filter(Boolean))]; + throw new Error( + `All accounts are invalid: ${reasons.join('; ') || 'unknown reason'}. Visit the WebUI to fix them.` + ); + } + + if (accountManager.isAllRateLimited(model)) { + const minWaitMs = accountManager.getMinWaitTimeMs(model); + const resetTime = new Date(Date.now() + minWaitMs).toISOString(); + + // If wait time is too long (> 2 minutes), try fallback first, then throw error + if (minWaitMs > MAX_WAIT_BEFORE_ERROR_MS) { + // Check if fallback is enabled and available + if (fallbackEnabled) { + const fallbackModel = getFallbackModel(model); + if (fallbackModel) { + logger.warn(`[CloudCode] All accounts exhausted for ${model} (${formatDuration(minWaitMs)} wait). Attempting fallback to ${fallbackModel} (streaming)`); + const fallbackRequest = { ...anthropicRequest, model: fallbackModel }; + yield* sendMessageStream(fallbackRequest, accountManager, false); + return; + } + } + throw new Error( + `RESOURCE_EXHAUSTED: Rate limited on ${model}. Quota will reset after ${formatDuration(minWaitMs)}. Next available: ${resetTime}` + ); + } + + // Wait for shortest reset time + const accountCount = accountManager.getAccountCount(); + logger.warn(`[CloudCode] All ${accountCount} account(s) rate-limited. Waiting ${formatDuration(minWaitMs)}...`); + await sleep(minWaitMs + 500); // Add 500ms buffer + accountManager.clearExpiredLimits(); + + // CRITICAL FIX: Don't count waiting for rate limits as a failed attempt + // This prevents "Max retries exceeded" when we are just patiently waiting + attempt--; + continue; // Retry the loop + } + + // No accounts available and not rate-limited (shouldn't happen normally) + throw new Error('No accounts available'); + } + + // Select account using configured strategy + const { account, waitMs } = accountManager.selectAccount(model); + + // If strategy returns a wait time without an account, sleep and retry + if (!account && waitMs > 0) { + logger.info(`[CloudCode] Waiting ${formatDuration(waitMs)} for account...`); + await sleep(waitMs + 500); + attempt--; // CRITICAL FIX: Don't count strategy wait as failure + continue; + } + + // If strategy returns an account with throttle wait (fallback mode), apply delay + // This prevents overwhelming the API when using emergency/lastResort fallbacks + if (account && waitMs > 0) { + logger.debug(`[CloudCode] Throttling request (${waitMs}ms) - fallback mode active`); + await sleep(waitMs); + } + + if (!account) { + logger.warn(`[CloudCode] Strategy returned no account for ${model} (attempt ${attempt + 1}/${maxAttempts})`); + continue; + } + + try { + // Get token and project for this account + const token = await accountManager.getTokenForAccount(account); + const project = await accountManager.getProjectForAccount(account, token); + const payload = buildCloudCodeRequest(anthropicRequest, project, account.email); + + logger.debug(`[CloudCode] Starting stream for model: ${model}`); + + // Try each endpoint with index-based loop for capacity retry support + let lastError = null; + let capacityRetryCount = 0; + let endpointIndex = 0; + + while (endpointIndex < ANTIGRAVITY_ENDPOINT_FALLBACKS.length) { + const endpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[endpointIndex]; + try { + const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`; + + const response = await throttledFetch(url, { + method: 'POST', + headers: buildHeaders(token, model, 'text/event-stream', payload.request.sessionId), + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.warn(`[CloudCode] Stream error at ${endpoint}: ${response.status} - ${errorText}`); + + if (response.status === 401) { + // Check for permanent auth failures + if (isPermanentAuthFailure(errorText)) { + logger.error(`[CloudCode] Permanent auth failure for ${account.email}: ${errorText.substring(0, 100)}`); + accountManager.markInvalid(account.email, 'Token revoked - re-authentication required'); + throw new Error(`AUTH_INVALID_PERMANENT: ${errorText}`); + } + + // Transient auth error - clear caches and retry + accountManager.clearTokenCache(account.email); + accountManager.clearProjectCache(account.email); + endpointIndex++; + continue; + } + + if (response.status === 429) { + const resetMs = parseResetTime(response, errorText); + const consecutiveFailures = accountManager.getConsecutiveFailures?.(account.email) || 0; + + // Check if capacity issue (NOT quota) - retry same endpoint with progressive backoff + if (isModelCapacityExhausted(errorText)) { + if (capacityRetryCount < MAX_CAPACITY_RETRIES) { + // Progressive capacity backoff tiers + const tierIndex = Math.min(capacityRetryCount, CAPACITY_BACKOFF_TIERS_MS.length - 1); + const waitMs = resetMs || CAPACITY_BACKOFF_TIERS_MS[tierIndex]; + capacityRetryCount++; + // Track failures for progressive backoff escalation (matches opencode-antigravity-auth) + accountManager.incrementConsecutiveFailures(account.email); + logger.info(`[CloudCode] Model capacity exhausted, retry ${capacityRetryCount}/${MAX_CAPACITY_RETRIES} after ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + // Max capacity retries exceeded - treat as quota exhaustion + logger.warn(`[CloudCode] Max capacity retries (${MAX_CAPACITY_RETRIES}) exceeded, switching account`); + } + + // Get rate limit backoff with exponential backoff and state reset + const backoff = getRateLimitBackoff(account.email, model, resetMs); + + // For very short rate limits (< 1 second), always wait and retry + // Switching accounts won't help when all accounts have per-second rate limits + if (resetMs !== null && resetMs < 1000) { + const waitMs = resetMs; + logger.info(`[CloudCode] Short rate limit on ${account.email} (${resetMs}ms), waiting and retrying...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + + // If within dedup window AND reset time is >= 1s, switch account + if (backoff.isDuplicate) { + const smartBackoffMs = calculateSmartBackoff(errorText, resetMs, consecutiveFailures); + logger.info(`[CloudCode] Skipping retry due to recent rate limit on ${account.email} (attempt ${backoff.attempt}), switching account...`); + accountManager.markRateLimited(account.email, smartBackoffMs, model); + throw new Error(`RATE_LIMITED_DEDUP: ${errorText}`); + } + + // Calculate smart backoff based on error type + const smartBackoffMs = calculateSmartBackoff(errorText, resetMs, consecutiveFailures); + + // Decision: wait and retry OR switch account + // First 429 gets a quick 1s retry (FIRST_RETRY_DELAY_MS) + if (backoff.attempt === 1 && smartBackoffMs <= DEFAULT_COOLDOWN_MS) { + // Quick 1s retry on first 429 (matches opencode-antigravity-auth) + const waitMs = backoff.delayMs; + // markRateLimited already increments consecutiveFailures internally + accountManager.markRateLimited(account.email, waitMs, model); + logger.info(`[CloudCode] First rate limit on ${account.email}, quick retry after ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } else if (smartBackoffMs > DEFAULT_COOLDOWN_MS) { + // Long-term quota exhaustion (> 10s) - wait SWITCH_ACCOUNT_DELAY_MS then switch + logger.info(`[CloudCode] Quota exhausted for ${account.email} (${formatDuration(smartBackoffMs)}), switching account after ${formatDuration(SWITCH_ACCOUNT_DELAY_MS)} delay...`); + await sleep(SWITCH_ACCOUNT_DELAY_MS); + accountManager.markRateLimited(account.email, smartBackoffMs, model); + throw new Error(`QUOTA_EXHAUSTED: ${errorText}`); + } else { + // Short-term rate limit but not first attempt - use exponential backoff delay + const waitMs = backoff.delayMs; + // markRateLimited already increments consecutiveFailures internally + accountManager.markRateLimited(account.email, waitMs, model); + logger.info(`[CloudCode] Rate limit on ${account.email} (attempt ${backoff.attempt}), waiting ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + } + + // Check for 503/529 MODEL_CAPACITY_EXHAUSTED - use progressive backoff like 429 capacity + // 529 = Site Overloaded (same treatment as 503) + if ((response.status === 503 || response.status === 529) && isModelCapacityExhausted(errorText)) { + if (capacityRetryCount < MAX_CAPACITY_RETRIES) { + // Progressive capacity backoff tiers (same as 429 capacity handling) + const tierIndex = Math.min(capacityRetryCount, CAPACITY_BACKOFF_TIERS_MS.length - 1); + const waitMs = CAPACITY_BACKOFF_TIERS_MS[tierIndex]; + capacityRetryCount++; + accountManager.incrementConsecutiveFailures(account.email); + logger.info(`[CloudCode] ${response.status} Model capacity exhausted, retry ${capacityRetryCount}/${MAX_CAPACITY_RETRIES} after ${formatDuration(waitMs)}...`); + await sleep(waitMs); + // Don't increment endpointIndex - retry same endpoint + continue; + } + // Max capacity retries exceeded - switch account + logger.warn(`[CloudCode] Max capacity retries (${MAX_CAPACITY_RETRIES}) exceeded on ${response.status}, switching account`); + accountManager.markRateLimited(account.email, BACKOFF_BY_ERROR_TYPE.MODEL_CAPACITY_EXHAUSTED, model); + throw new Error(`CAPACITY_EXHAUSTED: ${errorText}`); + } + + // 400 errors are client errors - fail immediately, don't retry or switch accounts + // Examples: token limit exceeded, invalid schema, malformed request + if (response.status === 400) { + logger.error(`[CloudCode] Invalid request (400): ${errorText.substring(0, 200)}`); + throw new Error(`invalid_request_error: ${errorText}`); + } + + // 403 with VALIDATION_REQUIRED or PERMISSION_DENIED is an account-level error + // The account needs validation (captcha, terms, etc.) - trying different endpoints won't help + // Mark account as invalid (requires user intervention) and rotate (fixes #248) + if (response.status === 403 && isValidationRequired(errorText)) { + const verifyUrl = extractVerificationUrl(errorText); + logger.warn(`[CloudCode] 403 VALIDATION_REQUIRED/PERMISSION_DENIED for ${account.email}, marking invalid and rotating account...`); + accountManager.markInvalid(account.email, 'Account requires verification', verifyUrl); + throw new AccountForbiddenError(errorText, account.email); + } + + // 403 with permanent ToS ban — account is permanently disabled by Google + // Unlike VALIDATION_REQUIRED, this cannot be resolved by verification + if (response.status === 403 && isAccountBanned(errorText)) { + logger.warn(`[CloudCode] 403 ToS BANNED for ${account.email}, marking invalid permanently...`); + accountManager.markInvalid(account.email, 'Account banned — Gemini disabled for Terms of Service violation'); + throw new AccountForbiddenError(errorText, account.email); + } + + lastError = new Error(`API error ${response.status}: ${errorText}`); + + // Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior) + if (response.status === 403 || response.status === 404) { + logger.warn(`[CloudCode] ${response.status} at ${endpoint}..`); + } else if (response.status >= 500) { + logger.warn(`[CloudCode] ${response.status} stream error, waiting 1s before retry...`); + await sleep(1000); + } + + endpointIndex++; + continue; + } + + // Stream the response with retry logic for empty responses + let currentResponse = response; + + for (let emptyRetries = 0; emptyRetries <= MAX_EMPTY_RESPONSE_RETRIES; emptyRetries++) { + try { + yield* streamSSEResponse(currentResponse, anthropicRequest.model); + logger.debug('[CloudCode] Stream completed'); + // Clear rate limit state on success + clearRateLimitState(account.email, model); + accountManager.notifySuccess(account, model); + return; + } catch (streamError) { + // Only retry on EmptyResponseError + if (!isEmptyResponseError(streamError)) { + throw streamError; + } + + // Check if we have retries left + if (emptyRetries >= MAX_EMPTY_RESPONSE_RETRIES) { + logger.error(`[CloudCode] Empty response after ${MAX_EMPTY_RESPONSE_RETRIES} retries`); + yield* emitEmptyResponseFallback(anthropicRequest.model); + return; + } + + // Exponential backoff: 500ms, 1000ms, 2000ms + const backoffMs = 500 * Math.pow(2, emptyRetries); + logger.warn(`[CloudCode] Empty response, retry ${emptyRetries + 1}/${MAX_EMPTY_RESPONSE_RETRIES} after ${backoffMs}ms...`); + await sleep(backoffMs); + + // Refetch the response + currentResponse = await throttledFetch(url, { + method: 'POST', + headers: buildHeaders(token, model, 'text/event-stream', payload.request.sessionId), + body: JSON.stringify(payload) + }); + + // Handle specific error codes on retry + if (!currentResponse.ok) { + const retryErrorText = await currentResponse.text(); + + // Rate limit error - mark account and throw to trigger account switch + if (currentResponse.status === 429) { + const resetMs = parseResetTime(currentResponse, retryErrorText); + accountManager.markRateLimited(account.email, resetMs, model); + throw new Error(`429 RESOURCE_EXHAUSTED during retry: ${retryErrorText}`); + } + + // Auth error - check for permanent failure + if (currentResponse.status === 401) { + if (isPermanentAuthFailure(retryErrorText)) { + logger.error(`[CloudCode] Permanent auth failure during retry for ${account.email}`); + accountManager.markInvalid(account.email, 'Token revoked - re-authentication required'); + throw new Error(`AUTH_INVALID_PERMANENT: ${retryErrorText}`); + } + accountManager.clearTokenCache(account.email); + accountManager.clearProjectCache(account.email); + throw new Error(`401 AUTH_INVALID during retry: ${retryErrorText}`); + } + + // For 5xx errors, continue retrying + if (currentResponse.status >= 500) { + logger.warn(`[CloudCode] Retry got ${currentResponse.status}, will retry...`); + await sleep(1000); + currentResponse = await throttledFetch(url, { + method: 'POST', + headers: buildHeaders(token, model, 'text/event-stream'), + body: JSON.stringify(payload) + }); + if (currentResponse.ok) { + continue; + } + } + + throw new Error(`Empty response retry failed: ${currentResponse.status} - ${retryErrorText}`); + } + } + } + + } catch (endpointError) { + if (isRateLimitError(endpointError)) { + throw endpointError; // Re-throw to trigger account switch + } + if (isEmptyResponseError(endpointError)) { + throw endpointError; + } + // 403 account-level errors - re-throw to trigger account rotation + if (isAccountForbiddenError(endpointError)) { + throw endpointError; + } + // 400 errors are client errors - re-throw immediately, don't retry + if (endpointError.message?.includes('400')) { + throw endpointError; + } + logger.warn(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message); + lastError = endpointError; + endpointIndex++; + } + } + + // If all endpoints failed for this account + if (lastError) { + if (lastError.is429) { + logger.warn(`[CloudCode] All endpoints rate-limited for ${account.email}`); + accountManager.markRateLimited(account.email, lastError.resetMs, model); + throw new Error(`Rate limited: ${lastError.errorText}`); + } + throw lastError; + } + + } catch (error) { + if (isRateLimitError(error)) { + // Rate limited - already marked, notify strategy and continue to next account + accountManager.notifyRateLimit(account, model); + logger.info(`[CloudCode] Account ${account.email} rate-limited, trying next...`); + + // CRITICAL FIX: If this is a duplicate rate limit (account was already known to be + // rate-limited), don't count it as a failed attempt. This prevents "Max retries exceeded" + // when thundering herd causes all accounts to be rate-limited and we're just cycling + // through known-bad accounts waiting for rate limits to expire. + if (error.message?.includes('RATE_LIMITED_DEDUP')) { + attempt--; + } + continue; + } + if (isAuthError(error)) { + // Auth invalid - already marked, continue to next account + logger.warn(`[CloudCode] Account ${account.email} has invalid credentials, trying next...`); + continue; + } + if (isAccountForbiddenError(error)) { + // 403 VALIDATION_REQUIRED / PERMISSION_DENIED - account-level error + // Already marked with cooldown, notify strategy and rotate to next account + accountManager.notifyFailure(account, model); + logger.warn(`[CloudCode] Account ${account.email} forbidden (403 VALIDATION_REQUIRED), trying next...`); + continue; + } + // Handle 5xx errors + if (error.message.includes('API error 5') || error.message.includes('500') || error.message.includes('503')) { + accountManager.notifyFailure(account, model); + + // Track 5xx errors for extended cooldown + // Note: markRateLimited already increments consecutiveFailures internally + const currentFailures = accountManager.getConsecutiveFailures(account.email); + if (currentFailures + 1 >= MAX_CONSECUTIVE_FAILURES) { + logger.warn(`[CloudCode] Account ${account.email} has ${currentFailures + 1} consecutive failures, applying extended cooldown (${formatDuration(EXTENDED_COOLDOWN_MS)})`); + accountManager.markRateLimited(account.email, EXTENDED_COOLDOWN_MS, model); + } else { + accountManager.incrementConsecutiveFailures(account.email); + logger.warn(`[CloudCode] Account ${account.email} failed with 5xx stream error (${currentFailures + 1}/${MAX_CONSECUTIVE_FAILURES}), trying next...`); + } + continue; + } + + if (isNetworkError(error)) { + accountManager.notifyFailure(account, model); + + // Track network errors for extended cooldown + // Note: markRateLimited already increments consecutiveFailures internally + const currentFailures = accountManager.getConsecutiveFailures(account.email); + if (currentFailures + 1 >= MAX_CONSECUTIVE_FAILURES) { + logger.warn(`[CloudCode] Account ${account.email} has ${currentFailures + 1} consecutive network failures, applying extended cooldown (${formatDuration(EXTENDED_COOLDOWN_MS)})`); + accountManager.markRateLimited(account.email, EXTENDED_COOLDOWN_MS, model); + } else { + accountManager.incrementConsecutiveFailures(account.email); + logger.warn(`[CloudCode] Network error for ${account.email} (stream) (${currentFailures + 1}/${MAX_CONSECUTIVE_FAILURES}), trying next account... (${error.message})`); + } + await sleep(1000); + continue; + } + + throw error; + } + } + + // All retries exhausted - try fallback model if enabled + if (fallbackEnabled) { + const fallbackModel = getFallbackModel(model); + if (fallbackModel) { + logger.warn(`[CloudCode] All retries exhausted for ${model}. Attempting fallback to ${fallbackModel} (streaming)`); + const fallbackRequest = { ...anthropicRequest, model: fallbackModel }; + yield* sendMessageStream(fallbackRequest, accountManager, false); + return; + } + } + + throw new Error('Max retries exceeded'); +} + +/** + * Emit a fallback message when all retry attempts fail with empty response + * @param {string} model - The model name + * @yields {Object} Anthropic-format SSE events for empty response fallback + */ +function* emitEmptyResponseFallback(model) { + // Use proper message ID format consistent with Anthropic API + const messageId = `msg_${crypto.randomBytes(16).toString('hex')}`; + + yield { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model: model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 } + } + }; + + yield { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' } + }; + + yield { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: '[No response after retries - please try again]' } + }; + + yield { type: 'content_block_stop', index: 0 }; + + yield { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 0 } + }; + + yield { type: 'message_stop' }; +} diff --git a/src/config.js b/src/config.js new file mode 100755 index 0000000000000000000000000000000000000000..5adf6350c18310733dbd864e5f3c4e6c7341a4e4 --- /dev/null +++ b/src/config.js @@ -0,0 +1,169 @@ +import './bootstrap/load-config-env.js'; + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { logger } from './utils/logger.js'; + +function isObject(item) { + return (item && typeof item === 'object' && !Array.isArray(item)); +} + +const DENIED_KEYS = ['__proto__', 'constructor', 'prototype']; + +function deepMerge(target, source) { + const output = { ...target }; + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach(key => { + if (DENIED_KEYS.includes(key)) return; + if (isObject(source[key])) { + if (!(key in target)) { + Object.assign(output, { [key]: source[key] }); + } else { + output[key] = deepMerge(target[key], source[key]); + } + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + } + return output; +} + +// Default config +const DEFAULT_CONFIG = { + apiKey: '', + webuiPassword: '', + debug: false, + devMode: false, + logLevel: 'info', + maxRetries: 5, + retryBaseMs: 1000, + retryMaxMs: 30000, + persistTokenCache: false, + defaultCooldownMs: 10000, // 10 seconds + maxWaitBeforeErrorMs: 120000, // 2 minutes + maxAccounts: 10, // Maximum number of accounts allowed + globalQuotaThreshold: 0, // 0 = disabled, 0.01-0.99 = minimum quota fraction before switching accounts + requestThrottlingEnabled: false, // Opt-in: enable delay before Google API requests + requestDelayMs: 200, // Delay in ms when throttling enabled (100-5000ms) + // Rate limit handling (matches opencode-antigravity-auth) + rateLimitDedupWindowMs: 2000, // 2 seconds - prevents concurrent retry storms + maxConsecutiveFailures: 3, // Before applying extended cooldown + extendedCooldownMs: 60000, // 1 minute extended cooldown + maxCapacityRetries: 5, // Max retries for capacity exhaustion + switchAccountDelayMs: 5000, // Delay before switching accounts on rate limit + capacityBackoffTiersMs: [5000, 10000, 20000, 30000, 60000], // Progressive backoff tiers for capacity exhaustion + modelMapping: {}, + // Account selection strategy configuration + accountSelection: { + strategy: 'hybrid', // 'sticky' | 'round-robin' | 'hybrid' + // Hybrid strategy tuning (optional - sensible defaults) + healthScore: { + initial: 70, // Starting score for new accounts + successReward: 1, // Points on successful request + rateLimitPenalty: -10, // Points on rate limit + failurePenalty: -20, // Points on other failures + recoveryPerHour: 10, // Passive recovery rate (matches health-tracker.js) + minUsable: 50, // Minimum score to be selected + maxScore: 100 // Maximum score cap + }, + tokenBucket: { + maxTokens: 50, // Maximum token capacity + tokensPerMinute: 6, // Regeneration rate + initialTokens: 50 // Starting tokens + }, + quota: { + lowThreshold: 0.10, // 10% - reduce score + criticalThreshold: 0.05, // 5% - exclude from candidates + staleMs: 300000 // 5 min - max age of quota data to trust + }, + weights: { + health: 2, // Weight for health score component + tokens: 5, // Weight for token bucket component + quota: 3, // Weight for quota awareness component + lru: 0.1 // Weight for LRU freshness component + } + } +}; + +// Config locations +const HOME_DIR = os.homedir(); +const CONFIG_DIR = path.join(HOME_DIR, '.config', 'antigravity-proxy'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); + +// Ensure config dir exists +if (!fs.existsSync(CONFIG_DIR)) { + try { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + } catch (err) { + // Ignore + } +} + +// Load config +let config = { ...DEFAULT_CONFIG }; + +function loadConfig() { + try { + // Env vars take precedence for initial defaults, but file overrides them if present? + // Usually Env > File > Default. + + if (fs.existsSync(CONFIG_FILE)) { + const fileContent = fs.readFileSync(CONFIG_FILE, 'utf8'); + const userConfig = JSON.parse(fileContent); + config = deepMerge(DEFAULT_CONFIG, userConfig); + } else { + // Try looking in current dir for config.json as fallback + const localConfigPath = path.resolve('config.json'); + if (fs.existsSync(localConfigPath)) { + const fileContent = fs.readFileSync(localConfigPath, 'utf8'); + const userConfig = JSON.parse(fileContent); + config = deepMerge(DEFAULT_CONFIG, userConfig); + } + } + + // Environment overrides + const envApiKey = process.env.API_KEY || process.env.PROXY_API_KEY; + if (envApiKey) config.apiKey = envApiKey; + if (process.env.WEBUI_PASSWORD) config.webuiPassword = process.env.WEBUI_PASSWORD; + if (process.env.DEBUG === 'true') config.debug = true; + if (process.env.DEV_MODE === 'true') config.devMode = true; + + // Backward compat: debug implies devMode + if (config.debug && !config.devMode) config.devMode = true; + + } catch (error) { + logger.error('[Config] Error loading config:', error); + } +} + +// Initial load +loadConfig(); + +export function getPublicConfig() { + // Create a deep copy and redact sensitive fields + const publicConfig = JSON.parse(JSON.stringify(config)); + + // Redact sensitive values + if (publicConfig.webuiPassword) publicConfig.webuiPassword = '********'; + if (publicConfig.apiKey) publicConfig.apiKey = '********'; + + return publicConfig; +} + +export function saveConfig(updates) { + try { + // Apply updates (deep merge to preserve nested configs) + config = deepMerge(config, updates); + + // Save to disk + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); + return true; + } catch (error) { + logger.error('[Config] Failed to save config:', error); + return false; + } +} + +export { config }; \ No newline at end of file diff --git a/src/constants.js b/src/constants.js new file mode 100755 index 0000000000000000000000000000000000000000..9e27d4647de1dd801fa73a27834d62ddc26e25af --- /dev/null +++ b/src/constants.js @@ -0,0 +1,527 @@ +/** + * Constants for Antigravity Cloud Code API integration + * Based on: https://github.com/NoeFabris/opencode-antigravity-auth + */ + +import { homedir, platform, arch } from 'os'; +import { join } from 'path'; +import { config } from './config.js'; +import { generateSmartUserAgent, getClientVersion } from './utils/version-detector.js'; + +/** + * Get the Antigravity database path based on the current platform. + * - macOS: ~/Library/Application Support/Antigravity/... + * - Windows: ~/AppData/Roaming/Antigravity/... + * - Linux/other: ~/.config/Antigravity/... + * @returns {string} Full path to the Antigravity state database + */ +function getAntigravityDbPath() { + const home = homedir(); + switch (platform()) { + case 'darwin': + return join(home, 'Library/Application Support/Antigravity/User/globalStorage/state.vscdb'); + case 'win32': + return join(home, 'AppData/Roaming/Antigravity/User/globalStorage/state.vscdb'); + default: // linux, freebsd, etc. + return join(home, '.config/Antigravity/User/globalStorage/state.vscdb'); + } +} + +/** + * Generate platform-specific User-Agent string. + * @returns {string} User-Agent in format "antigravity/version os/arch" + */ +export function getPlatformUserAgent() { + return generateSmartUserAgent(); +} + +// IDE Type enum (numeric values as expected by Cloud Code API) +// Reference: Antigravity binary analysis - google.internal.cloud.code.v1internal.ClientMetadata.IdeType +export const IDE_TYPE = { + UNSPECIFIED: 0, + JETSKI: 10, // Internal codename for Gemini CLI + ANTIGRAVITY: 9, + PLUGINS: 7 +}; + +// Platform enum (as specified in Antigravity binary) +export const PLATFORM = { + UNSPECIFIED: 0, + DARWIN_AMD64: 1, + DARWIN_ARM64: 2, + LINUX_AMD64: 3, + LINUX_ARM64: 4, + WINDOWS_AMD64: 5 +}; + +// Plugin type enum (as specified in Antigravity binary) +export const PLUGIN_TYPE = { + UNSPECIFIED: 0, + CLOUD_CODE: 1, + GEMINI: 2 +}; + +/** + * Get the platform enum value based on the current OS. + * @returns {number} Platform enum value + */ +function getPlatformEnum() { + const os = platform(); + const architecture = arch(); + + if (os === 'darwin') { + return architecture === 'arm64' ? PLATFORM.DARWIN_ARM64 : PLATFORM.DARWIN_AMD64; + } else if (os === 'linux') { + return architecture === 'arm64' ? PLATFORM.LINUX_ARM64 : PLATFORM.LINUX_AMD64; + } else if (os === 'win32') { + return PLATFORM.WINDOWS_AMD64; + } + return PLATFORM.UNSPECIFIED; +} + +// Centralized client metadata (used in request bodies for loadCodeAssist, onboardUser, etc.) +// Using numeric enum values as expected by the Cloud Code API +export const CLIENT_METADATA = { + ideType: IDE_TYPE.ANTIGRAVITY, // 6 - identifies as Antigravity client + platform: getPlatformEnum(), // Runtime platform detection + pluginType: PLUGIN_TYPE.GEMINI // 2 +}; + +// Cloud Code API endpoints (in fallback order) +const ANTIGRAVITY_ENDPOINT_DAILY = 'https://daily-cloudcode-pa.googleapis.com'; +const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com'; + +// Endpoint fallback order (daily → prod) +export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ + ANTIGRAVITY_ENDPOINT_DAILY, + ANTIGRAVITY_ENDPOINT_PROD +]; + +// Required headers for Antigravity API requests +// Headers for general Antigravity API requests +// Strictly matches the generic 'u' method in main.js +export const ANTIGRAVITY_HEADERS = { + 'User-Agent': getPlatformUserAgent(), + 'Content-Type': 'application/json', + 'X-Client-Name': 'antigravity', + 'X-Client-Version': getClientVersion(), + 'x-goog-api-client': 'gl-node/18.18.2 fire/0.8.6 grpc/1.10.x', + 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1', + 'Client-Metadata': '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}' +}; + +// Endpoint order for loadCodeAssist (prod first) +// loadCodeAssist works better on prod for fresh/unprovisioned accounts +export const LOAD_CODE_ASSIST_ENDPOINTS = [ + ANTIGRAVITY_ENDPOINT_PROD, + ANTIGRAVITY_ENDPOINT_DAILY +]; + +// Endpoint order for onboardUser (same as generateContent fallbacks) +export const ONBOARD_USER_ENDPOINTS = ANTIGRAVITY_ENDPOINT_FALLBACKS; + +// Headers for loadCodeAssist API +// Matches the minimal headers seen in the binary's u() method +export const LOAD_CODE_ASSIST_HEADERS = ANTIGRAVITY_HEADERS; + +// Default project ID if none can be discovered +export const DEFAULT_PROJECT_ID = 'rising-fact-p41fc'; + +// Configurable constants - values from config.json take precedence +export const TOKEN_REFRESH_INTERVAL_MS = config?.tokenCacheTtlMs || (5 * 60 * 1000); // From config or 5 minutes +export const REQUEST_BODY_LIMIT = config?.requestBodyLimit || '50mb'; +export const ANTIGRAVITY_AUTH_PORT = 9092; +export const DEFAULT_PORT = config?.port || 8080; + +// Multi-account configuration +export const ACCOUNT_CONFIG_PATH = process.env.ACCOUNT_CONFIG_PATH || config?.accountConfigPath || join( + homedir(), + '.config/antigravity-proxy/accounts.json' +); + +// Usage history persistence path +export const USAGE_HISTORY_PATH = join( + homedir(), + '.config/antigravity-proxy/usage-history.json' +); + +// Antigravity app database path (for legacy single-account token extraction) +// Uses platform-specific path detection +export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath(); + +export const DEFAULT_COOLDOWN_MS = config?.defaultCooldownMs || (10 * 1000); // From config or 10 seconds +export const MAX_RETRIES = config?.maxRetries || 5; // From config or 5 +export const MAX_EMPTY_RESPONSE_RETRIES = 2; // Max retries for empty API responses (from upstream) +export const MAX_ACCOUNTS = config?.maxAccounts || 10; // From config or 10 + +// Rate limit wait thresholds +export const MAX_WAIT_BEFORE_ERROR_MS = config?.maxWaitBeforeErrorMs || 120000; // From config or 2 minutes + +// Retry deduplication - prevents thundering herd on concurrent rate limits +export const RATE_LIMIT_DEDUP_WINDOW_MS = config?.rateLimitDedupWindowMs || 2000; // 2 seconds +export const RATE_LIMIT_STATE_RESET_MS = config?.rateLimitStateResetMs || 120000; // 2 minutes - reset consecutive429 after inactivity +export const FIRST_RETRY_DELAY_MS = config?.firstRetryDelayMs || 1000; // Quick 1s retry on first 429 +export const SWITCH_ACCOUNT_DELAY_MS = config?.switchAccountDelayMs || 5000; // Delay before switching accounts + +// Consecutive failure tracking - extended cooldown after repeated failures +export const MAX_CONSECUTIVE_FAILURES = config?.maxConsecutiveFailures || 3; +export const EXTENDED_COOLDOWN_MS = config?.extendedCooldownMs || 60000; // 1 minute + +// Capacity exhaustion - progressive backoff tiers for model capacity issues +export const CAPACITY_BACKOFF_TIERS_MS = config?.capacityBackoffTiersMs || [5000, 10000, 20000, 30000, 60000]; +export const MAX_CAPACITY_RETRIES = config?.maxCapacityRetries || 5; + +// Smart backoff by error type +export const BACKOFF_BY_ERROR_TYPE = { + RATE_LIMIT_EXCEEDED: 30000, // 30 seconds + MODEL_CAPACITY_EXHAUSTED: 15000, // 15 seconds + SERVER_ERROR: 20000, // 20 seconds + UNKNOWN: 60000 // 1 minute +}; + +// Progressive backoff tiers for QUOTA_EXHAUSTED (60s, 5m, 30m, 2h) +export const QUOTA_EXHAUSTED_BACKOFF_TIERS_MS = [60000, 300000, 1800000, 7200000]; + +// Minimum backoff floor to prevent "Available in 0s" loops (matches opencode-antigravity-auth) +export const MIN_BACKOFF_MS = 2000; + +// Jitter range for capacity backoff (Thundering Herd Prevention) +// Applied to MODEL_CAPACITY_EXHAUSTED to stagger client retries +export const CAPACITY_JITTER_MAX_MS = 10000; // ±5s jitter range + +// Thinking model constants +export const MIN_SIGNATURE_LENGTH = 50; // Minimum valid thinking signature length + +// Account selection strategies +export const SELECTION_STRATEGIES = ['sticky', 'round-robin', 'hybrid']; +export const DEFAULT_SELECTION_STRATEGY = 'hybrid'; + +// Strategy display labels +export const STRATEGY_LABELS = { + 'sticky': 'Sticky (Cache Optimized)', + 'round-robin': 'Round Robin (Load Balanced)', + 'hybrid': 'Hybrid (Smart Distribution)' +}; + +// Gemini-specific limits +export const GEMINI_MAX_OUTPUT_TOKENS = 16384; + +// Gemini signature handling +// Sentinel value to skip thought signature validation when Claude Code strips the field +// See: https://ai.google.dev/gemini-api/docs/thought-signatures +export const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator'; + +// Cache TTL for Gemini thoughtSignatures (2 hours) +export const GEMINI_SIGNATURE_CACHE_TTL_MS = 2 * 60 * 60 * 1000; + +// Cache TTL for model validation (5 minutes) +export const MODEL_VALIDATION_CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Get the model family from model name (dynamic detection, no hardcoded list). + * @param {string} modelName - The model name from the request + * @returns {'claude' | 'gemini' | 'unknown'} The model family + */ +export function getModelFamily(modelName) { + const lower = (modelName || '').toLowerCase(); + if (lower.includes('claude')) return 'claude'; + if (lower.includes('gemini')) return 'gemini'; + return 'unknown'; +} + +/** + * Check if a model supports thinking/reasoning output. + * @param {string} modelName - The model name from the request + * @returns {boolean} True if the model supports thinking blocks + */ +export function isThinkingModel(modelName) { + const lower = (modelName || '').toLowerCase(); + // Pro tier SKUs use non-stream generateContent; streamGenerateContent returns INVALID_ARGUMENT. + if (/gemini-3(?:\.\d+)?-pro-(?:high|low)$/i.test(lower)) return false; + // Claude thinking models have "thinking" in the name + if (lower.includes('claude') && lower.includes('thinking')) return true; + // Gemini thinking models: explicit "thinking" in name, OR gemini version 3+ + if (lower.includes('gemini')) { + if (lower.includes('thinking')) return true; + // Check for gemini-3 or higher (e.g., gemini-3, gemini-3.5, gemini-4, etc.) + const versionMatch = lower.match(/gemini-(\d+)/); + if (versionMatch && parseInt(versionMatch[1], 10) >= 3) return true; + } + return false; +} + +// Google OAuth configuration (from opencode-antigravity-auth) +// OAuth callback port - configurable via environment variable for Windows compatibility (issue #176) +// Windows may reserve ports in range 49152-65535 for Hyper-V/WSL2/Docker, causing EACCES errors +const OAUTH_CALLBACK_PORT = parseInt(process.env.OAUTH_CALLBACK_PORT || '51121', 10); +const OAUTH_CALLBACK_FALLBACK_PORTS = [51122, 51123, 51124, 51125, 51126]; + +export const OAUTH_CONFIG = { + clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', + clientSecret: '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', + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo', + callbackPort: OAUTH_CALLBACK_PORT, + callbackFallbackPorts: OAUTH_CALLBACK_FALLBACK_PORTS, + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' + ] +}; +export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_CONFIG.callbackPort}/oauth-callback`; + +// Minimal Antigravity system instruction (from CLIProxyAPI) +// Only includes the essential identity portion to reduce token usage and improve response quality +// Reference: GitHub issue #76, CLIProxyAPI, gcli2api +export const ANTIGRAVITY_SYSTEM_INSTRUCTION = `You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**`; + +// Model fallback mapping - maps primary model to fallback when quota exhausted +export const MODEL_FALLBACK_MAP = { + 'gemini-3.1-pro-high': 'claude-opus-4-6-thinking', + 'gemini-3.1-pro-low': 'claude-sonnet-4-6', + 'gemini-3-flash': 'claude-sonnet-4-6', + 'claude-opus-4-6-thinking': 'gemini-3.1-pro-high', + 'claude-sonnet-4-6': 'gemini-3-flash' +}; + +// Default test models for each family (used by test suite) +export const TEST_MODELS = { + claude: 'claude-sonnet-4-6', + gemini: 'gemini-3.5-flash-low' +}; + +// Default Claude CLI presets (used by WebUI settings) +export const DEFAULT_PRESETS = [ + { + name: 'Claude Thinking', + config: { + ANTHROPIC_AUTH_TOKEN: 'test', + ANTHROPIC_BASE_URL: 'http://localhost:8080', + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-4-6', + ENABLE_EXPERIMENTAL_MCP_CLI: 'true' + } + }, + { + name: 'Gemini 1M', + config: { + ANTHROPIC_AUTH_TOKEN: 'test', + ANTHROPIC_BASE_URL: 'http://localhost:8080', + ANTHROPIC_MODEL: 'gemini-3.1-pro-low', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.1-pro-low', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.5-flash-low', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3.5-flash-low', + CLAUDE_CODE_SUBAGENT_MODEL: 'gemini-3.5-flash-low', + ENABLE_EXPERIMENTAL_MCP_CLI: 'true' + } + } +]; + +/** + * Built-in server configuration presets. + * Each preset has builtIn: true and cannot be deleted by users. + */ +export const DEFAULT_SERVER_PRESETS = [ + { + name: 'Default (3-5 Accounts)', + builtIn: true, + descriptionKey: 'presetDefaultDesc', + config: { + maxRetries: 5, + retryBaseMs: 1000, + retryMaxMs: 30000, + defaultCooldownMs: 10000, + maxWaitBeforeErrorMs: 120000, + maxAccounts: 10, + globalQuotaThreshold: 0, + rateLimitDedupWindowMs: 2000, + maxConsecutiveFailures: 3, + extendedCooldownMs: 60000, + maxCapacityRetries: 5, + switchAccountDelayMs: 5000, + capacityBackoffTiersMs: [5000, 10000, 20000, 30000, 60000], + accountSelection: { + strategy: 'hybrid', + healthScore: { + initial: 70, + successReward: 1, + rateLimitPenalty: -10, + failurePenalty: -20, + recoveryPerHour: 10, + minUsable: 50, + maxScore: 100 + }, + tokenBucket: { + maxTokens: 50, + tokensPerMinute: 6, + initialTokens: 50 + }, + quota: { + lowThreshold: 0.10, + criticalThreshold: 0.05, + staleMs: 300000 + }, + weights: { + health: 2, + tokens: 5, + quota: 3, + lru: 0.1 + } + } + } + }, + { + name: 'Many Accounts (10+)', + builtIn: true, + descriptionKey: 'presetManyAccountsDesc', + config: { + maxRetries: 3, + retryBaseMs: 500, + retryMaxMs: 15000, + defaultCooldownMs: 5000, + maxWaitBeforeErrorMs: 60000, + maxAccounts: 50, + globalQuotaThreshold: 0.10, + rateLimitDedupWindowMs: 1000, + maxConsecutiveFailures: 2, + extendedCooldownMs: 30000, + maxCapacityRetries: 3, + switchAccountDelayMs: 3000, + capacityBackoffTiersMs: [3000, 6000, 12000, 20000, 40000], + accountSelection: { + strategy: 'hybrid', + healthScore: { + initial: 70, + successReward: 1, + rateLimitPenalty: -15, + failurePenalty: -25, + recoveryPerHour: 5, + minUsable: 40, + maxScore: 100 + }, + tokenBucket: { + maxTokens: 30, + tokensPerMinute: 8, + initialTokens: 30 + }, + quota: { + lowThreshold: 0.15, + criticalThreshold: 0.05, + staleMs: 180000 + }, + weights: { + health: 5, + tokens: 2, + quota: 3, + lru: 0.01 + } + } + } + }, + { + name: 'Conservative', + builtIn: true, + descriptionKey: 'presetConservativeDesc', + config: { + maxRetries: 8, + retryBaseMs: 2000, + retryMaxMs: 60000, + defaultCooldownMs: 20000, + maxWaitBeforeErrorMs: 240000, + maxAccounts: 10, + globalQuotaThreshold: 0.20, + rateLimitDedupWindowMs: 3000, + maxConsecutiveFailures: 5, + extendedCooldownMs: 120000, + maxCapacityRetries: 8, + switchAccountDelayMs: 8000, + capacityBackoffTiersMs: [8000, 15000, 30000, 45000, 90000], + accountSelection: { + strategy: 'sticky', + healthScore: { + initial: 80, + successReward: 2, + rateLimitPenalty: -5, + failurePenalty: -10, + recoveryPerHour: 3, + minUsable: 50, + maxScore: 100 + }, + tokenBucket: { + maxTokens: 80, + tokensPerMinute: 4, + initialTokens: 80 + }, + quota: { + lowThreshold: 0.20, + criticalThreshold: 0.10, + staleMs: 300000 + }, + weights: { + health: 3, + tokens: 4, + quota: 2, + lru: 0.05 + } + } + } + } +]; + +export default { + IDE_TYPE, + PLATFORM, + PLUGIN_TYPE, + CLIENT_METADATA, + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_HEADERS, + LOAD_CODE_ASSIST_ENDPOINTS, + ONBOARD_USER_ENDPOINTS, + LOAD_CODE_ASSIST_HEADERS, + DEFAULT_PROJECT_ID, + TOKEN_REFRESH_INTERVAL_MS, + REQUEST_BODY_LIMIT, + ANTIGRAVITY_AUTH_PORT, + DEFAULT_PORT, + ACCOUNT_CONFIG_PATH, + ANTIGRAVITY_DB_PATH, + DEFAULT_COOLDOWN_MS, + MAX_RETRIES, + MAX_EMPTY_RESPONSE_RETRIES, + MAX_ACCOUNTS, + MAX_WAIT_BEFORE_ERROR_MS, + RATE_LIMIT_DEDUP_WINDOW_MS, + RATE_LIMIT_STATE_RESET_MS, + FIRST_RETRY_DELAY_MS, + SWITCH_ACCOUNT_DELAY_MS, + MAX_CONSECUTIVE_FAILURES, + EXTENDED_COOLDOWN_MS, + CAPACITY_BACKOFF_TIERS_MS, + MAX_CAPACITY_RETRIES, + BACKOFF_BY_ERROR_TYPE, + QUOTA_EXHAUSTED_BACKOFF_TIERS_MS, + MIN_BACKOFF_MS, + CAPACITY_JITTER_MAX_MS, + MIN_SIGNATURE_LENGTH, + GEMINI_MAX_OUTPUT_TOKENS, + GEMINI_SKIP_SIGNATURE, + GEMINI_SIGNATURE_CACHE_TTL_MS, + MODEL_VALIDATION_CACHE_TTL_MS, + getModelFamily, + isThinkingModel, + OAUTH_CONFIG, + OAUTH_REDIRECT_URI, + STRATEGY_LABELS, + MODEL_FALLBACK_MAP, + TEST_MODELS, + DEFAULT_PRESETS, + DEFAULT_SERVER_PRESETS, + ANTIGRAVITY_SYSTEM_INSTRUCTION +}; diff --git a/src/errors.js b/src/errors.js new file mode 100755 index 0000000000000000000000000000000000000000..bf7d25690d2f0c50204b3306873c1dc6e06ed044 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,271 @@ +/** + * Custom Error Classes + * + * Provides structured error types for better error handling and classification. + * Replaces string-based error detection with proper error class checking. + */ + +/** + * Base error class for Antigravity proxy errors + */ +export class AntigravityError extends Error { + /** + * @param {string} message - Error message + * @param {string} code - Error code for programmatic handling + * @param {boolean} retryable - Whether the error is retryable + * @param {Object} metadata - Additional error metadata + */ + constructor(message, code, retryable = false, metadata = {}) { + super(message); + this.name = 'AntigravityError'; + this.code = code; + this.retryable = retryable; + this.metadata = metadata; + } + + /** + * Convert to JSON for API responses + */ + toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + retryable: this.retryable, + ...this.metadata + }; + } +} + +/** + * Rate limit error (429 / RESOURCE_EXHAUSTED) + */ +export class RateLimitError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {number|null} resetMs - Time in ms until rate limit resets + * @param {string} accountEmail - Email of the rate-limited account + */ + constructor(message, resetMs = null, accountEmail = null) { + super(message, 'RATE_LIMITED', true, { resetMs, accountEmail }); + this.name = 'RateLimitError'; + this.resetMs = resetMs; + this.accountEmail = accountEmail; + } +} + +/** + * Authentication error (invalid credentials, token expired, etc.) + */ +export class AuthError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {string} accountEmail - Email of the account with auth issues + * @param {string} reason - Specific reason for auth failure + */ + constructor(message, accountEmail = null, reason = null) { + super(message, 'AUTH_INVALID', false, { accountEmail, reason }); + this.name = 'AuthError'; + this.accountEmail = accountEmail; + this.reason = reason; + } +} + +/** + * No accounts available error + */ +export class NoAccountsError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {boolean} allRateLimited - Whether all accounts are rate limited + */ + constructor(message = 'No accounts available', allRateLimited = false) { + super(message, 'NO_ACCOUNTS', allRateLimited, { allRateLimited }); + this.name = 'NoAccountsError'; + this.allRateLimited = allRateLimited; + } +} + +/** + * Max retries exceeded error + */ +export class MaxRetriesError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {number} attempts - Number of attempts made + */ + constructor(message = 'Max retries exceeded', attempts = 0) { + super(message, 'MAX_RETRIES', false, { attempts }); + this.name = 'MaxRetriesError'; + this.attempts = attempts; + } +} + +/** + * API error from upstream service + */ +export class ApiError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {number} statusCode - HTTP status code + * @param {string} errorType - Type of API error + */ + constructor(message, statusCode = 500, errorType = 'api_error') { + super(message, errorType.toUpperCase(), statusCode >= 500, { statusCode, errorType }); + this.name = 'ApiError'; + this.statusCode = statusCode; + this.errorType = errorType; + } +} + +/** + * Native module error (version mismatch, rebuild required) + */ +export class NativeModuleError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {boolean} rebuildSucceeded - Whether auto-rebuild succeeded + * @param {boolean} restartRequired - Whether server restart is needed + */ + constructor(message, rebuildSucceeded = false, restartRequired = false) { + super(message, 'NATIVE_MODULE_ERROR', false, { rebuildSucceeded, restartRequired }); + this.name = 'NativeModuleError'; + this.rebuildSucceeded = rebuildSucceeded; + this.restartRequired = restartRequired; + } +} + +/** + * Empty response error - thrown when API returns no content + * Used to trigger retry logic in streaming handler + */ +export class EmptyResponseError extends AntigravityError { + /** + * @param {string} message - Error message + */ + constructor(message = 'No content received from API') { + super(message, 'EMPTY_RESPONSE', true, {}); + this.name = 'EmptyResponseError'; + } +} + +/** + * Capacity exhausted error - Google's model is at capacity (not user quota) + * Should retry on same account with shorter delay, not switch accounts immediately + * Different from QUOTA_EXHAUSTED which indicates user's daily/hourly limit + */ +export class CapacityExhaustedError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {number|null} retryAfterMs - Suggested retry delay in ms + */ + constructor(message = 'Model capacity exhausted', retryAfterMs = null) { + super(message, 'CAPACITY_EXHAUSTED', true, { retryAfterMs }); + this.name = 'CapacityExhaustedError'; + this.retryAfterMs = retryAfterMs; + } +} + +/** + * Account forbidden error (403 VALIDATION_REQUIRED / PERMISSION_DENIED) + * These are account-level errors where the account needs validation or has been + * disabled. Trying different endpoints won't help - need to rotate to another account. + */ +export class AccountForbiddenError extends AntigravityError { + /** + * @param {string} message - Error message + * @param {string} accountEmail - Email of the forbidden account + */ + constructor(message, accountEmail = null) { + super(message, 'ACCOUNT_FORBIDDEN', false, { accountEmail }); + this.name = 'AccountForbiddenError'; + this.accountEmail = accountEmail; + } +} + +/** + * Check if an error is an account forbidden error (403 VALIDATION_REQUIRED / PERMISSION_DENIED) + * These errors indicate the account itself is blocked and need account rotation, not endpoint rotation. + * @param {Error} error - Error to check + * @returns {boolean} + */ +export function isAccountForbiddenError(error) { + if (error instanceof AccountForbiddenError) return true; + // Fallback string check only for errors that couldn't use the typed class + // (e.g., errors crossing module boundaries). Only match our own prefixed format. + const msg = (error.message || ''); + return msg.startsWith('ACCOUNT_FORBIDDEN:'); +} + +/** + * Check if an error is a rate limit error + * Works with both custom error classes and legacy string-based errors + * @param {Error} error - Error to check + * @returns {boolean} + */ +export function isRateLimitError(error) { + if (error instanceof RateLimitError) return true; + const msg = (error.message || '').toLowerCase(); + return msg.includes('429') || + msg.includes('resource_exhausted') || + msg.includes('quota_exhausted') || + msg.includes('rate limit'); +} + +/** + * Check if an error is an authentication error + * Works with both custom error classes and legacy string-based errors + * @param {Error} error - Error to check + * @returns {boolean} + */ +export function isAuthError(error) { + if (error instanceof AuthError) return true; + const msg = (error.message || '').toUpperCase(); + return msg.includes('AUTH_INVALID') || + msg.includes('INVALID_GRANT') || + msg.includes('TOKEN REFRESH FAILED'); +} + +/** + * Check if an error is an empty response error + * @param {Error} error - Error to check + * @returns {boolean} + */ +export function isEmptyResponseError(error) { + return error instanceof EmptyResponseError || + error?.name === 'EmptyResponseError'; +} + +/** + * Check if an error is a capacity exhausted error (model overload, not user quota) + * This is different from quota exhaustion - capacity issues are temporary infrastructure + * limits that should be retried on the SAME account with shorter delays + * @param {Error} error - Error to check + * @returns {boolean} + */ +export function isCapacityExhaustedError(error) { + if (error instanceof CapacityExhaustedError) return true; + const msg = (error.message || '').toLowerCase(); + return msg.includes('model_capacity_exhausted') || + msg.includes('capacity_exhausted') || + msg.includes('model is currently overloaded') || + msg.includes('service temporarily unavailable'); +} + +export default { + AntigravityError, + RateLimitError, + AuthError, + AccountForbiddenError, + NoAccountsError, + MaxRetriesError, + ApiError, + NativeModuleError, + EmptyResponseError, + CapacityExhaustedError, + isRateLimitError, + isAuthError, + isAccountForbiddenError, + isEmptyResponseError, + isCapacityExhaustedError +}; diff --git a/src/fallback-config.js b/src/fallback-config.js new file mode 100755 index 0000000000000000000000000000000000000000..894cdeee7a42d300c2992c86e62ec1bc91e9ba81 --- /dev/null +++ b/src/fallback-config.js @@ -0,0 +1,29 @@ +/** + * Model Fallback Configuration + * + * Defines fallback mappings for when a model's quota is exhausted across all accounts. + * Enables graceful degradation to alternative models with similar capabilities. + */ + +import { MODEL_FALLBACK_MAP } from './constants.js'; + +// Re-export for convenience +export { MODEL_FALLBACK_MAP }; + +/** + * Get fallback model for a given model ID + * @param {string} model - Primary model ID + * @returns {string|null} Fallback model ID or null if no fallback exists + */ +export function getFallbackModel(model) { + return MODEL_FALLBACK_MAP[model] || null; +} + +/** + * Check if a model has a fallback configured + * @param {string} model - Model ID to check + * @returns {boolean} True if fallback exists + */ +export function hasFallback(model) { + return model in MODEL_FALLBACK_MAP; +} diff --git a/src/format/content-converter.js b/src/format/content-converter.js new file mode 100755 index 0000000000000000000000000000000000000000..ed6233de4c885b267f3cec3343b90881c0b8d804 --- /dev/null +++ b/src/format/content-converter.js @@ -0,0 +1,201 @@ +/** + * Content Converter + * Converts Anthropic message content to Google Generative AI parts format + */ + +import { MIN_SIGNATURE_LENGTH, GEMINI_SKIP_SIGNATURE } from '../constants.js'; +import { getCachedSignature, getCachedSignatureFamily } from './signature-cache.js'; +import { logger } from '../utils/logger.js'; + +/** + * Convert Anthropic role to Google role + * @param {string} role - Anthropic role ('user', 'assistant') + * @returns {string} Google role ('user', 'model') + */ +export function convertRole(role) { + if (role === 'assistant') return 'model'; + if (role === 'user') return 'user'; + return 'user'; // Default to user +} + +/** + * Convert Anthropic message content to Google Generative AI parts + * @param {string|Array} content - Anthropic message content + * @param {boolean} isClaudeModel - Whether the model is a Claude model + * @param {boolean} isGeminiModel - Whether the model is a Gemini model + * @returns {Array} Google Generative AI parts array + */ +export function convertContentToParts(content, isClaudeModel = false, isGeminiModel = false) { + if (typeof content === 'string') { + return [{ text: content }]; + } + + if (!Array.isArray(content)) { + return [{ text: String(content) }]; + } + + const parts = []; + const deferredInlineData = []; // Collect inlineData to add at the end (Issue #91) + + for (const block of content) { + if (!block) continue; + + if (block.type === 'text') { + // Skip empty text blocks - they cause API errors + if (block.text && block.text.trim()) { + parts.push({ text: block.text }); + } + } else if (block.type === 'image') { + // Handle image content + if (block.source?.type === 'base64') { + // Base64-encoded image + parts.push({ + inlineData: { + mimeType: block.source.media_type, + data: block.source.data + } + }); + } else if (block.source?.type === 'url') { + // URL-referenced image + parts.push({ + fileData: { + mimeType: block.source.media_type || 'image/jpeg', + fileUri: block.source.url + } + }); + } + } else if (block.type === 'document') { + // Handle document content (e.g. PDF) + if (block.source?.type === 'base64') { + parts.push({ + inlineData: { + mimeType: block.source.media_type, + data: block.source.data + } + }); + } else if (block.source?.type === 'url') { + parts.push({ + fileData: { + mimeType: block.source.media_type || 'application/pdf', + fileUri: block.source.url + } + }); + } + } else if (block.type === 'tool_use') { + // Convert tool_use to functionCall (Google format) + // For Claude models, include the id field + const functionCall = { + name: block.name, + args: block.input || {} + }; + + if (isClaudeModel && block.id) { + functionCall.id = block.id; + } + + // Build the part with functionCall + const part = { functionCall }; + + // For Gemini models, include thoughtSignature at the part level + // This is required by Gemini 3+ for tool calls to work correctly + if (isGeminiModel) { + // Priority: block.thoughtSignature > cache > GEMINI_SKIP_SIGNATURE + let signature = block.thoughtSignature; + + if (!signature && block.id) { + signature = getCachedSignature(block.id); + if (signature) { + logger.debug(`[ContentConverter] Restored signature from cache for: ${block.id}`); + } + } + + part.thoughtSignature = signature || GEMINI_SKIP_SIGNATURE; + } + + parts.push(part); + } else if (block.type === 'tool_result') { + // Convert tool_result to functionResponse (Google format) + let responseContent = block.content; + let imageParts = []; + + if (typeof responseContent === 'string') { + responseContent = { result: responseContent }; + } else if (Array.isArray(responseContent)) { + // Extract images from tool results first (e.g., from Read tool reading image files) + for (const item of responseContent) { + if (item.type === 'image' && item.source?.type === 'base64') { + imageParts.push({ + inlineData: { + mimeType: item.source.media_type, + data: item.source.data + } + }); + } + } + + // Extract text content + const texts = responseContent + .filter(c => c.type === 'text') + .map(c => c.text) + .join('\n'); + responseContent = { result: texts || (imageParts.length > 0 ? 'Image attached' : '') }; + } + + const functionResponse = { + name: block.name || block.tool_use_id || 'unknown', + response: responseContent + }; + + // For Claude models, the id field must match the tool_use_id + if (isClaudeModel && block.tool_use_id) { + functionResponse.id = block.tool_use_id; + } + + parts.push({ functionResponse }); + + // Defer images from the tool result to end of parts array (Issue #91) + // This ensures all functionResponse parts are consecutive + deferredInlineData.push(...imageParts); + } else if (block.type === 'thinking') { + // Handle thinking blocks with signature compatibility check + if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) { + const signatureFamily = getCachedSignatureFamily(block.signature); + const targetFamily = isClaudeModel ? 'claude' : isGeminiModel ? 'gemini' : null; + + // For Claude: drop unknown or non-Claude signatures. + // This prevents resumed sessions from forwarding stale signatures that + // fail upstream validation with "Invalid `signature` in `thinking` block". + if (isClaudeModel && (!signatureFamily || signatureFamily !== 'claude')) { + logger.debug('[ContentConverter] Dropping untrusted thinking signature for Claude model'); + continue; + } + + // Drop blocks with incompatible signatures for Gemini (cross-model switch) + if (isGeminiModel && signatureFamily && targetFamily && signatureFamily !== targetFamily) { + logger.debug(`[ContentConverter] Dropping incompatible ${signatureFamily} thinking for ${targetFamily} model`); + continue; + } + + // Drop blocks with unknown signature origin for Gemini (cold cache - safe default) + if (isGeminiModel && !signatureFamily && targetFamily) { + logger.debug(`[ContentConverter] Dropping thinking with unknown signature origin`); + continue; + } + + // Compatible - convert to Gemini format with signature + parts.push({ + text: block.thinking, + thought: true, + thoughtSignature: block.signature + }); + } + // Unsigned thinking blocks are dropped (existing behavior) + } + } + + // Add deferred inlineData at the end (Issue #91) + // This ensures functionResponse parts are consecutive, which Claude's API requires + parts.push(...deferredInlineData); + + return parts; +} diff --git a/src/format/index.js b/src/format/index.js new file mode 100755 index 0000000000000000000000000000000000000000..74193cde998b28a92a603be9c836544851e307c0 --- /dev/null +++ b/src/format/index.js @@ -0,0 +1,24 @@ +/** + * Format Converter Module + * Converts between Anthropic Messages API format and Google Generative AI format + */ + +// Re-export all from each module +export * from './request-converter.js'; +export * from './response-converter.js'; +export * from './content-converter.js'; +export * from './schema-sanitizer.js'; +export * from './thinking-utils.js'; +export * from './model-aliases.js'; +export * from './openai-compat.js'; +export * from './openai-stream.js'; +export * from './reasoning-effort.js'; + +// Default export for backward compatibility +import { convertAnthropicToGoogle } from './request-converter.js'; +import { convertGoogleToAnthropic } from './response-converter.js'; + +export default { + convertAnthropicToGoogle, + convertGoogleToAnthropic +}; diff --git a/src/format/model-aliases.js b/src/format/model-aliases.js new file mode 100755 index 0000000000000000000000000000000000000000..fa65fa882cf8484ce0b92ee69a9b665f9eb023b0 --- /dev/null +++ b/src/format/model-aliases.js @@ -0,0 +1,77 @@ +/** + * Legacy model aliases used by the old Hugging Face proxy. + */ + +export const LEGACY_MODEL_ALIASES = Object.freeze({ + 'antigravity/gemini-3-flash': 'gemini-3.5-flash-low', + 'gemini-3-flash': 'gemini-3.5-flash-low', + 'antigravity/gemini-3-pro-low': 'gemini-3.1-pro-low', + 'gemini-3-pro-low': 'gemini-3.1-pro-low', + 'antigravity/gemini-3-pro-high': 'gemini-3.1-pro-high', + 'gemini-3-pro-high': 'gemini-3.1-pro-high' +}); + +export function resolveCloudCodeApiModel(requestedModel) { + const model = String(requestedModel || '').trim(); + if (/gemini-3(?:\.\d+)?-pro-high$/i.test(model)) { + return model.replace(/pro-high$/i, 'pro-low'); + } + return model; +} + +export function applyProHighThinkingFallback(googleRequest, requestedModel) { + if (!/gemini-3(?:\.\d+)?-pro-high$/i.test(String(requestedModel || ''))) { + return; + } + googleRequest.generationConfig = googleRequest.generationConfig || {}; + googleRequest.generationConfig.thinkingConfig = { + includeThoughts: true, + thinkingLevel: 'high' + }; +} + +export function stripProviderPrefix(modelName) { + const model = String(modelName || '').trim(); + if (!model) return ''; + return model.startsWith('antigravity/') ? model.slice('antigravity/'.length) : model; +} + +export function resolveModelAlias(modelName, configuredMappings = {}) { + const original = String(modelName || '').trim(); + const stripped = stripProviderPrefix(original); + + const configured = configuredMappings?.[original]?.mapping + || configuredMappings?.[stripped]?.mapping; + if (configured) return configured; + + return LEGACY_MODEL_ALIASES[original] + || LEGACY_MODEL_ALIASES[stripped] + || stripped; +} + +export function appendLegacyModelAliases(modelListResponse) { + const data = Array.isArray(modelListResponse?.data) + ? [...modelListResponse.data] + : []; + const existing = new Set(data.map(model => model.id)); + const created = Math.floor(Date.now() / 1000); + + for (const [alias, target] of Object.entries(LEGACY_MODEL_ALIASES)) { + if (!alias.startsWith('antigravity/')) continue; + if (!existing.has(target) || existing.has(alias)) continue; + + data.push({ + id: alias, + object: 'model', + created, + owned_by: 'antigravity-proxy', + description: `Legacy alias for ${target}` + }); + existing.add(alias); + } + + return { + object: 'list', + data + }; +} diff --git a/src/format/openai-compat.js b/src/format/openai-compat.js new file mode 100755 index 0000000000000000000000000000000000000000..2af3dde1e795796dfe0442364b5bd3cae59745cb --- /dev/null +++ b/src/format/openai-compat.js @@ -0,0 +1,472 @@ + +/** + * OpenAI Chat Completions compatibility helpers. + * + * This module only translates API shapes. It does not call Cloud Code directly. + */ + +import crypto from 'crypto'; +import { resolveModelAlias } from './model-aliases.js'; + +export class OpenAICompatError extends Error { + constructor(message, { + statusCode = 400, + type = 'invalid_request_error', + code = 'invalid_request', + param = null + } = {}) { + super(message); + this.name = 'OpenAICompatError'; + this.statusCode = statusCode; + this.type = type; + this.code = code; + this.param = param; + } +} + +export function createOpenAIError(message, { + type = 'api_error', + code = null, + param = null +} = {}) { + return { + error: { + message, + type, + param, + code + } + }; +} + +export function classifyOpenAIError(error) { + if (error instanceof OpenAICompatError) { + return { + statusCode: error.statusCode, + body: createOpenAIError(error.message, { + type: error.type, + code: error.code, + param: error.param + }) + }; + } + + const message = String(error?.message || 'Internal server error'); + const upper = message.toUpperCase(); + + if (upper.includes('401') || upper.includes('UNAUTHENTICATED') || upper.includes('AUTH_INVALID')) { + return { + statusCode: 401, + body: createOpenAIError('Authentication failed.', { + type: 'authentication_error', + code: 'invalid_api_key' + }) + }; + } + + if (upper.includes('429') || upper.includes('RESOURCE_EXHAUSTED') || upper.includes('RATE LIMIT')) { + return { + statusCode: 429, + body: createOpenAIError(message, { + type: 'rate_limit_error', + code: 'rate_limit_exceeded' + }) + }; + } + + if (upper.includes('INVALID MODEL') || upper.includes('INVALID_REQUEST_ERROR') || upper.includes('INVALID_ARGUMENT')) { + return { + statusCode: 400, + body: createOpenAIError(message.replace(/^invalid_request_error:\s*/i, ''), { + type: 'invalid_request_error', + code: 'invalid_request' + }) + }; + } + + if (upper.includes('PERMISSION_DENIED') || upper.includes('ACCOUNT_FORBIDDEN')) { + return { + statusCode: 403, + body: createOpenAIError(message, { + type: 'permission_error', + code: 'permission_denied' + }) + }; + } + + if (upper.includes('TIMEOUT')) { + return { + statusCode: 504, + body: createOpenAIError(message, { + type: 'api_error', + code: 'timeout' + }) + }; + } + + if (upper.includes('NO ACCOUNTS AVAILABLE') || upper.includes('ALL ENDPOINTS FAILED')) { + return { + statusCode: 503, + body: createOpenAIError(message, { + type: 'api_error', + code: 'service_unavailable' + }) + }; + } + + return { + statusCode: 500, + body: createOpenAIError(message, { + type: 'api_error', + code: 'internal_error' + }) + }; +} + +function normalizeTextContent(content) { + if (content === null || content === undefined) return ''; + if (typeof content === 'string') return content; + + if (!Array.isArray(content)) { + throw new OpenAICompatError('message.content must be a string, null, or an array', { + param: 'messages' + }); + } + + return content + .filter(part => part && (part.type === 'text' || part.type === 'input_text')) + .map(part => part.text || '') + .join('\n'); +} + +function convertOpenAIContentParts(content) { + if (typeof content === 'string') return content; + if (content === null || content === undefined) return ''; + if (!Array.isArray(content)) { + throw new OpenAICompatError('message.content must be a string, null, or an array', { + param: 'messages' + }); + } + + const blocks = []; + for (const part of content) { + if (!part) continue; + + if (part.type === 'text' || part.type === 'input_text') { + if (part.text) blocks.push({ type: 'text', text: part.text }); + continue; + } + + if (part.type === 'image_url') { + const url = typeof part.image_url === 'string' + ? part.image_url + : part.image_url?.url; + if (!url) { + throw new OpenAICompatError('image_url content requires image_url.url', { + param: 'messages' + }); + } + + const dataUrlMatch = url.match(/^data:([^;,]+);base64,(.+)$/s); + if (dataUrlMatch) { + blocks.push({ + type: 'image', + source: { + type: 'base64', + media_type: dataUrlMatch[1], + data: dataUrlMatch[2] + } + }); + } else { + blocks.push({ + type: 'image', + source: { + type: 'url', + media_type: 'image/jpeg', + url + } + }); + } + continue; + } + + throw new OpenAICompatError(`Unsupported message content part type: ${part.type || 'unknown'}`, { + param: 'messages' + }); + } + + return blocks; +} + +function parseToolArguments(rawArguments, param) { + if (rawArguments === undefined || rawArguments === null || rawArguments === '') return {}; + if (typeof rawArguments === 'object') return rawArguments; + if (typeof rawArguments !== 'string') { + throw new OpenAICompatError('tool call arguments must be a JSON string or object', { param }); + } + + try { + return JSON.parse(rawArguments); + } catch { + throw new OpenAICompatError('tool call arguments must contain valid JSON', { param }); + } +} + +function appendMessage(messages, role, content) { + const previous = messages[messages.length - 1]; + if (previous && previous.role === role && Array.isArray(previous.content) && Array.isArray(content)) { + previous.content.push(...content); + return; + } + messages.push({ role, content }); +} + +function convertMessages(openAIMessages) { + if (!Array.isArray(openAIMessages) || openAIMessages.length === 0) { + throw new OpenAICompatError('messages is required and must be a non-empty array', { + param: 'messages' + }); + } + + const systemParts = []; + const messages = []; + const toolNamesById = new Map(); + + for (let index = 0; index < openAIMessages.length; index++) { + const message = openAIMessages[index]; + if (!message || typeof message !== 'object') { + throw new OpenAICompatError(`messages[${index}] must be an object`, { + param: `messages[${index}]` + }); + } + + const role = message.role; + if (role === 'system' || role === 'developer') { + const text = normalizeTextContent(message.content); + if (text) systemParts.push(text); + continue; + } + + if (role === 'user') { + appendMessage(messages, 'user', convertOpenAIContentParts(message.content)); + continue; + } + + if (role === 'assistant') { + const blocks = []; + const convertedContent = convertOpenAIContentParts(message.content); + if (typeof convertedContent === 'string') { + if (convertedContent) blocks.push({ type: 'text', text: convertedContent }); + } else { + blocks.push(...convertedContent); + } + + if (Array.isArray(message.tool_calls)) { + for (let toolIndex = 0; toolIndex < message.tool_calls.length; toolIndex++) { + const toolCall = message.tool_calls[toolIndex]; + if (toolCall?.type !== 'function' || !toolCall.function?.name) { + throw new OpenAICompatError('Only function tool_calls are supported', { + param: `messages[${index}].tool_calls[${toolIndex}]` + }); + } + const id = toolCall.id || `call_${crypto.randomUUID().replace(/-/g, '')}`; + toolNamesById.set(id, toolCall.function.name); + blocks.push({ + type: 'tool_use', + id, + name: toolCall.function.name, + input: parseToolArguments( + toolCall.function.arguments, + `messages[${index}].tool_calls[${toolIndex}].function.arguments` + ) + }); + } + } + + appendMessage(messages, 'assistant', blocks.length > 0 ? blocks : ''); + continue; + } + + if (role === 'tool') { + if (!message.tool_call_id) { + throw new OpenAICompatError('tool messages require tool_call_id', { + param: `messages[${index}].tool_call_id` + }); + } + const content = normalizeTextContent(message.content); + appendMessage(messages, 'user', [{ + type: 'tool_result', + tool_use_id: message.tool_call_id, + name: message.name || toolNamesById.get(message.tool_call_id), + content + }]); + continue; + } + + throw new OpenAICompatError(`Unsupported message role: ${role}`, { + param: `messages[${index}].role` + }); + } + + if (messages.length === 0) { + throw new OpenAICompatError('At least one non-system message is required', { + param: 'messages' + }); + } + + return { + system: systemParts.length > 0 ? systemParts.join('\n\n') : undefined, + messages + }; +} + +function convertTools(tools) { + if (tools === undefined) return undefined; + if (!Array.isArray(tools)) { + throw new OpenAICompatError('tools must be an array', { param: 'tools' }); + } + + return tools.map((tool, index) => { + if (['web_search', 'web_search_preview', 'google_search'].includes(tool?.type)) { + return { googleSearch: {} }; + } + + if (tool?.type !== 'function' || !tool.function?.name) { + throw new OpenAICompatError('Only function and web search tools are supported', { + param: `tools[${index}]` + }); + } + + return { + name: tool.function.name, + description: tool.function.description || '', + input_schema: tool.function.parameters || { type: 'object', properties: {} } + }; + }); +} + +function convertToolChoice(toolChoice) { + if (toolChoice === undefined || toolChoice === null || toolChoice === 'auto') { + return undefined; + } + if (toolChoice === 'none') return { type: 'none' }; + if (toolChoice === 'required') return { type: 'any' }; + + if (typeof toolChoice === 'object' && toolChoice.type === 'function' && toolChoice.function?.name) { + return { type: 'tool', name: toolChoice.function.name }; + } + + throw new OpenAICompatError('Unsupported tool_choice value', { param: 'tool_choice' }); +} + +function normalizeStop(stop) { + if (stop === undefined || stop === null) return undefined; + if (typeof stop === 'string') return [stop]; + if (Array.isArray(stop) && stop.every(item => typeof item === 'string')) return stop; + throw new OpenAICompatError('stop must be a string or an array of strings', { param: 'stop' }); +} + +export function convertOpenAIRequestToAnthropic(openAIRequest, configuredMappings = {}) { + if (!openAIRequest || typeof openAIRequest !== 'object' || Array.isArray(openAIRequest)) { + throw new OpenAICompatError('Request body must be a JSON object'); + } + + if (!openAIRequest.model || typeof openAIRequest.model !== 'string') { + throw new OpenAICompatError('model is required and must be a string', { param: 'model' }); + } + if (openAIRequest.n !== undefined && openAIRequest.n !== 1) { + throw new OpenAICompatError('Only n=1 is supported', { param: 'n' }); + } + if (openAIRequest.logprobs === true) { + throw new OpenAICompatError('logprobs is not supported', { param: 'logprobs' }); + } + if (openAIRequest.response_format && openAIRequest.response_format.type !== 'text') { + throw new OpenAICompatError('response_format is not supported in this migration', { + param: 'response_format' + }); + } + + const convertedMessages = convertMessages(openAIRequest.messages); + const model = resolveModelAlias(openAIRequest.model, configuredMappings); + const maxTokens = openAIRequest.max_completion_tokens + ?? openAIRequest.max_tokens + ?? 4096; + + if (!Number.isInteger(maxTokens) || maxTokens <= 0) { + throw new OpenAICompatError('max_tokens must be a positive integer', { + param: openAIRequest.max_completion_tokens !== undefined + ? 'max_completion_tokens' + : 'max_tokens' + }); + } + + return { + model, + messages: convertedMessages.messages, + system: convertedMessages.system, + max_tokens: maxTokens, + stream: openAIRequest.stream === true, + tools: convertTools(openAIRequest.tools), + tool_choice: convertToolChoice(openAIRequest.tool_choice), + temperature: openAIRequest.temperature, + top_p: openAIRequest.top_p, + top_k: openAIRequest.top_k, + stop_sequences: normalizeStop(openAIRequest.stop), + reasoning_effort: openAIRequest.reasoning_effort ?? 'auto' + }; +} + +function mapFinishReason(stopReason) { + if (stopReason === 'max_tokens') return 'length'; + if (stopReason === 'tool_use') return 'tool_calls'; + return 'stop'; +} + +export function convertAnthropicResponseToOpenAI(anthropicResponse, requestedModel) { + const text = []; + const toolCalls = []; + + for (const block of anthropicResponse?.content || []) { + if (block?.type === 'text') { + text.push(block.text || ''); + } else if (block?.type === 'tool_use') { + toolCalls.push({ + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input || {}) + } + }); + } + } + + const message = { + role: 'assistant', + content: text.length > 0 ? text.join('') : null + }; + if (toolCalls.length > 0) message.tool_calls = toolCalls; + + const inputTokens = anthropicResponse?.usage?.input_tokens || 0; + const outputTokens = anthropicResponse?.usage?.output_tokens || 0; + + return { + id: anthropicResponse?.id?.replace(/^msg_/, 'chatcmpl_') + || `chatcmpl_${crypto.randomUUID().replace(/-/g, '')}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: requestedModel, + choices: [{ + index: 0, + message, + finish_reason: mapFinishReason(anthropicResponse?.stop_reason), + logprobs: null + }], + usage: { + prompt_tokens: inputTokens, + completion_tokens: outputTokens, + total_tokens: inputTokens + outputTokens + } + }; +} + diff --git a/src/format/openai-stream.js b/src/format/openai-stream.js new file mode 100755 index 0000000000000000000000000000000000000000..f8f69518be19b05df4b01475c9992ed6d6e86186 --- /dev/null +++ b/src/format/openai-stream.js @@ -0,0 +1,123 @@ + +/** + * OpenAI Chat Completions streaming conversion helpers. + */ + +import crypto from 'crypto'; + +function mapFinishReason(stopReason) { + if (stopReason === 'max_tokens') return 'length'; + if (stopReason === 'tool_use') return 'tool_calls'; + return 'stop'; +} + +export function createOpenAIStreamState(requestedModel, includeUsage = false) { + return { + id: `chatcmpl_${crypto.randomUUID().replace(/-/g, '')}`, + created: Math.floor(Date.now() / 1000), + model: requestedModel, + includeUsage, + started: false, + finishReason: null, + promptTokens: 0, + completionTokens: 0, + toolCallIndexes: new Map(), + nextToolCallIndex: 0 + }; +} + +function makeChunk(state, delta, finishReason = null, usage = undefined) { + const chunk = { + id: state.id, + object: 'chat.completion.chunk', + created: state.created, + model: state.model, + choices: [{ + index: 0, + delta, + finish_reason: finishReason, + logprobs: null + }] + }; + if (usage !== undefined) chunk.usage = usage; + return chunk; +} + +export function convertAnthropicEventToOpenAIChunks(event, state) { + const chunks = []; + if (!event || !state) return chunks; + + if (event.type === 'message_start') { + state.id = event.message?.id?.replace(/^msg_/, 'chatcmpl_') || state.id; + state.promptTokens = event.message?.usage?.input_tokens || 0; + if (!state.started) { + state.started = true; + chunks.push(makeChunk(state, { role: 'assistant', content: '' })); + } + return chunks; + } + + if (!state.started) { + state.started = true; + chunks.push(makeChunk(state, { role: 'assistant', content: '' })); + } + + if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') { + const toolIndex = state.nextToolCallIndex++; + state.toolCallIndexes.set(event.index, toolIndex); + chunks.push(makeChunk(state, { + tool_calls: [{ + index: toolIndex, + id: event.content_block.id, + type: 'function', + function: { + name: event.content_block.name, + arguments: '' + } + }] + })); + return chunks; + } + + if (event.type === 'content_block_delta') { + if (event.delta?.type === 'text_delta') { + chunks.push(makeChunk(state, { content: event.delta.text || '' })); + } else if (event.delta?.type === 'input_json_delta') { + const toolIndex = state.toolCallIndexes.get(event.index) ?? 0; + chunks.push(makeChunk(state, { + tool_calls: [{ + index: toolIndex, + function: { + arguments: event.delta.partial_json || '' + } + }] + })); + } + return chunks; + } + + if (event.type === 'message_delta') { + state.finishReason = mapFinishReason(event.delta?.stop_reason); + state.completionTokens = event.usage?.output_tokens || 0; + chunks.push(makeChunk(state, {}, state.finishReason)); + return chunks; + } + + if (event.type === 'message_stop' && state.includeUsage) { + chunks.push({ + id: state.id, + object: 'chat.completion.chunk', + created: state.created, + model: state.model, + choices: [], + usage: { + prompt_tokens: state.promptTokens, + completion_tokens: state.completionTokens, + total_tokens: state.promptTokens + state.completionTokens + } + }); + } + + return chunks; +} + diff --git a/src/format/reasoning-effort.js b/src/format/reasoning-effort.js new file mode 100755 index 0000000000000000000000000000000000000000..af88522954e7963863f3f864f4689e2a237ba43a --- /dev/null +++ b/src/format/reasoning-effort.js @@ -0,0 +1,132 @@ + +/** + * OpenAI reasoning_effort to Cloud Code thinking configuration. + * Ported from llm-proxy-rotate AntigravityProvider._get_thinking_config(). + */ + +export const VALID_REASONING_EFFORTS = new Set([ + 'auto', + 'disable', + 'off', + 'none', + 'minimal', + 'low', + 'low_medium', + 'medium', + 'medium_high', + 'high' +]); + +export function normalizeReasoningEffort(value) { + if (value === undefined || value === null) return 'auto'; + if (typeof value !== 'string') return 'auto'; + + const normalized = value.trim().toLowerCase() || 'auto'; + return VALID_REASONING_EFFORTS.has(normalized) ? normalized : 'auto'; +} + +/** Pro tier is encoded in SKU names like gemini-3.1-pro-high / gemini-3.1-pro-low. */ +export function isGeminiProTierSku(modelName) { + return /gemini-3(?:\.\d+)?-pro-(?:high|low)$/i.test(String(modelName || '')); +} + +export function getReasoningThinkingConfig(modelName, reasoningEffort) { + const model = String(modelName || '').toLowerCase(); + const effort = normalizeReasoningEffort(reasoningEffort); + + // Tier SKUs already encode thinking level; extra thinkingConfig causes INVALID_ARGUMENT. + if (isGeminiProTierSku(model)) { + return null; + } + + const isGemini3Flash = /gemini-3(?:\.\d+)?-[^/]*flash/.test(model); + const isGemini3Pro = /gemini-3(?:\.\d+)?-[^/]*pro/.test(model); + const isGemini25 = model.includes('gemini-2.5'); + const isClaude = model.includes('claude'); + + if (!isGemini3Flash && !isGemini3Pro && !isGemini25 && !isClaude) { + return null; + } + + if (isGemini3Flash) { + let thinkingLevel = 'high'; + if (['disable', 'off', 'none'].includes(effort)) { + thinkingLevel = 'minimal'; + } else if (['minimal', 'low'].includes(effort)) { + thinkingLevel = 'low'; + } else if (['low_medium', 'medium'].includes(effort)) { + thinkingLevel = 'medium'; + } + + return { + effort, + mode: 'level', + config: { + includeThoughts: true, + thinkingLevel + } + }; + } + + if (isGemini3Pro) { + const thinkingLevel = [ + 'disable', 'off', 'none', 'minimal', 'low', 'low_medium' + ].includes(effort) ? 'low' : 'high'; + + return { + effort, + mode: 'level', + config: { + includeThoughts: true, + thinkingLevel + } + }; + } + + if (['disable', 'off', 'none'].includes(effort)) { + return { + effort, + mode: 'budget', + config: isClaude + ? { include_thoughts: false, thinking_budget: 0 } + : { includeThoughts: false, thinkingBudget: 0 } + }; + } + + if (effort === 'auto') { + return { + effort, + mode: 'budget', + config: isClaude + ? { include_thoughts: true, thinking_budget: -1 } + : { includeThoughts: true, thinkingBudget: -1 } + }; + } + + const budgets = model.includes('gemini-2.5-flash') + ? { + minimal: 3072, + low: 6144, + low_medium: 9216, + medium: 12288, + medium_high: 18432, + high: 24576 + } + : { + minimal: 4096, + low: 8192, + low_medium: 12288, + medium: 16384, + medium_high: 24576, + high: isClaude ? 31999 : 32768 + }; + + return { + effort, + mode: 'budget', + config: isClaude + ? { include_thoughts: true, thinking_budget: budgets[effort] } + : { includeThoughts: true, thinkingBudget: budgets[effort] } + }; +} + diff --git a/src/format/request-converter.js b/src/format/request-converter.js new file mode 100755 index 0000000000000000000000000000000000000000..c603b9caf8b0199684e3ddd9b290bd7a8096bb62 --- /dev/null +++ b/src/format/request-converter.js @@ -0,0 +1,311 @@ +/** + * Request Converter + * Converts Anthropic Messages API requests to Google Generative AI format + */ + +import { + GEMINI_MAX_OUTPUT_TOKENS, + getModelFamily, + isThinkingModel +} from '../constants.js'; +import { convertContentToParts, convertRole } from './content-converter.js'; +import { sanitizeSchema, cleanSchema } from './schema-sanitizer.js'; +import { + restoreThinkingSignatures, + removeTrailingThinkingBlocks, + reorderAssistantContent, + filterUnsignedThinkingBlocks, + hasGeminiHistory, + hasUnsignedThinkingBlocks, + needsThinkingRecovery, + closeToolLoopForThinking, + cleanCacheControl, + clampGeminiThinkingBudget +} from './thinking-utils.js'; +import { logger } from '../utils/logger.js'; +import { getReasoningThinkingConfig, isGeminiProTierSku } from './reasoning-effort.js'; + +/** + * Convert Anthropic Messages API request to the format expected by Cloud Code + * + * Uses Google Generative AI format, but for Claude models: + * - Keeps tool_result in Anthropic format (required by Claude API) + * + * @param {Object} anthropicRequest - Anthropic format request + * @returns {Object} Request body for Cloud Code API + */ +export function convertAnthropicToGoogle(anthropicRequest) { + // [CRITICAL FIX] Pre-clean all cache_control fields from messages (Issue #189) + // Claude Code CLI sends cache_control on various content blocks, but Cloud Code API + // rejects them with "Extra inputs are not permitted". Clean them proactively here + // before any other processing, following the pattern from Antigravity-Manager. + const messages = cleanCacheControl(anthropicRequest.messages || []); + + const { system, max_tokens, temperature, top_p, top_k, stop_sequences, tools, tool_choice, thinking, reasoning_effort } = anthropicRequest; + const modelName = anthropicRequest.model || ''; + const modelFamily = getModelFamily(modelName); + const isClaudeModel = modelFamily === 'claude'; + const isGeminiModel = modelFamily === 'gemini'; + const isThinking = isThinkingModel(modelName); + + const googleRequest = { + contents: [], + generationConfig: {} + }; + + // Handle system instruction + if (system) { + let systemParts = []; + if (typeof system === 'string') { + systemParts = [{ text: system }]; + } else if (Array.isArray(system)) { + // Filter for text blocks as system prompts are usually text + // Anthropic supports text blocks in system prompts + systemParts = system + .filter(block => block.type === 'text') + .map(block => ({ text: block.text })); + } + + if (systemParts.length > 0) { + googleRequest.systemInstruction = { + parts: systemParts + }; + } + } + + // Add interleaved thinking hint for Claude thinking models with tools + if (isClaudeModel && isThinking && tools && tools.length > 0) { + const hint = 'Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer.'; + if (!googleRequest.systemInstruction) { + googleRequest.systemInstruction = { parts: [{ text: hint }] }; + } else { + const lastPart = googleRequest.systemInstruction.parts[googleRequest.systemInstruction.parts.length - 1]; + if (lastPart && lastPart.text) { + lastPart.text = `${lastPart.text}\n\n${hint}`; + } else { + googleRequest.systemInstruction.parts.push({ text: hint }); + } + } + } + + // Apply thinking recovery for Gemini thinking models when needed + // Gemini needs recovery for tool loops/interrupted tools (stripped thinking) + let processedMessages = messages; + + if (isGeminiModel && isThinking && needsThinkingRecovery(messages)) { + logger.debug('[RequestConverter] Applying thinking recovery for Gemini'); + processedMessages = closeToolLoopForThinking(messages, 'gemini'); + } + + // For Claude: apply recovery for cross-model (Gemini→Claude) or unsigned thinking blocks + // Unsigned thinking blocks occur when Claude Code strips signatures it doesn't understand + const needsClaudeRecovery = hasGeminiHistory(messages) || hasUnsignedThinkingBlocks(messages); + if (isClaudeModel && isThinking && needsClaudeRecovery && needsThinkingRecovery(messages)) { + logger.debug('[RequestConverter] Applying thinking recovery for Claude'); + processedMessages = closeToolLoopForThinking(messages, 'claude'); + } + + // Convert messages to contents, then filter unsigned thinking blocks + for (const msg of processedMessages) { + let msgContent = msg.content; + + // For assistant messages, process thinking blocks and reorder content + if ((msg.role === 'assistant' || msg.role === 'model') && Array.isArray(msgContent)) { + // First, try to restore signatures for unsigned thinking blocks from cache + msgContent = restoreThinkingSignatures(msgContent); + // Remove trailing unsigned thinking blocks + msgContent = removeTrailingThinkingBlocks(msgContent); + // Reorder: thinking first, then text, then tool_use + msgContent = reorderAssistantContent(msgContent); + } + + const parts = convertContentToParts(msgContent, isClaudeModel, isGeminiModel); + + // SAFETY: Google API requires at least one part per content message + // This happens when all thinking blocks are filtered out (unsigned) + if (parts.length === 0) { + // Use '.' instead of '' because claude models reject empty text parts. + // A single period is invisible in practice but satisfies the API requirement. + logger.warn('[RequestConverter] WARNING: Empty parts array after filtering, adding placeholder'); + parts.push({ text: '.' }); + } + + const content = { + role: convertRole(msg.role), + parts: parts + }; + googleRequest.contents.push(content); + } + + // Filter unsigned thinking blocks for Claude models + if (isClaudeModel) { + googleRequest.contents = filterUnsignedThinkingBlocks(googleRequest.contents); + } + + // Generation config + if (max_tokens) { + googleRequest.generationConfig.maxOutputTokens = max_tokens; + } + if (temperature !== undefined) { + googleRequest.generationConfig.temperature = temperature; + } + if (top_p !== undefined) { + googleRequest.generationConfig.topP = top_p; + } + if (top_k !== undefined) { + googleRequest.generationConfig.topK = top_k; + } + if (stop_sequences && stop_sequences.length > 0) { + googleRequest.generationConfig.stopSequences = stop_sequences; + } + + // Gemini 3.x performs better with temperature=1 when unset (ported from old Python proxy). + if (isGeminiModel && /gemini-3(?:\.\d+)?-/i.test(modelName) && temperature === undefined) { + googleRequest.generationConfig.temperature = 1; + } + + // OpenAI reasoning_effort takes precedence when supplied by the adapter. + // Anthropic /v1/messages requests without reasoning_effort keep the existing behavior. + const reasoningConfig = reasoning_effort !== undefined + ? getReasoningThinkingConfig(modelName, reasoning_effort) + : null; + + if (reasoningConfig) { + googleRequest.generationConfig.thinkingConfig = reasoningConfig.config; + // #region agent log + fetch('http://127.0.0.1:7334/ingest/27587085-8299-4f8d-827a-e9584444390b',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'c04864'},body:JSON.stringify({sessionId:'c04864',location:'request-converter.js:thinking-applied',message:'thinkingConfig applied',data:{modelName,reasoning_effort,thinkingConfig:reasoningConfig.config},timestamp:Date.now(),hypothesisId:'H1'})}).catch(()=>{}); + // #endregion + const budget = reasoningConfig.config.thinkingBudget + ?? reasoningConfig.config.thinking_budget; + const detail = reasoningConfig.mode === 'level' + ? `thinkingLevel=${reasoningConfig.config.thinkingLevel}` + : `thinkingBudget=${budget}`; + logger.debug(`[RequestConverter] reasoning_effort=${reasoningConfig.effort} -> ${detail}`); + + if (isClaudeModel && budget > 0) { + const currentMaxTokens = googleRequest.generationConfig.maxOutputTokens; + if (currentMaxTokens && currentMaxTokens <= budget) { + const adjustedMaxTokens = budget + 8192; + logger.warn(`[RequestConverter] max_tokens (${currentMaxTokens}) <= thinking_budget (${budget}). Adjusting to ${adjustedMaxTokens}`); + googleRequest.generationConfig.maxOutputTokens = adjustedMaxTokens; + } + } + } else if (isThinking) { + if (isClaudeModel) { + // Claude thinking config + const thinkingConfig = { + include_thoughts: true + }; + + // Cloud Code API requires thinking_budget to actually produce thinking blocks. + // Without it, include_thoughts alone is ignored and Claude falls back to + // XML tags in text. Default to 32000 when not provided (e.g. adaptive mode). + const thinkingBudget = thinking?.budget_tokens || 32000; + thinkingConfig.thinking_budget = thinkingBudget; + logger.debug(`[RequestConverter] Claude thinking enabled with budget: ${thinkingBudget}${!thinking?.budget_tokens ? ' (default)' : ''}`); + + // Validate max_tokens > thinking_budget as required by the API + const currentMaxTokens = googleRequest.generationConfig.maxOutputTokens; + if (currentMaxTokens && currentMaxTokens <= thinkingBudget) { + const adjustedMaxTokens = thinkingBudget + 8192; + if (thinking?.budget_tokens) { + logger.warn(`[RequestConverter] max_tokens (${currentMaxTokens}) <= thinking_budget (${thinkingBudget}). Adjusting to ${adjustedMaxTokens} to satisfy API requirements`); + } else { + logger.debug(`[RequestConverter] Adjusting max_tokens to ${adjustedMaxTokens} for default thinking budget`); + } + googleRequest.generationConfig.maxOutputTokens = adjustedMaxTokens; + } + + googleRequest.generationConfig.thinkingConfig = thinkingConfig; + } else if (isGeminiModel && !isGeminiProTierSku(modelName)) { + // Preserve the existing Anthropic behavior when no OpenAI reasoning_effort exists. + const thinkingConfig = { + includeThoughts: true, + thinkingBudget: clampGeminiThinkingBudget(modelName, thinking?.budget_tokens) + }; + logger.debug(`[RequestConverter] Gemini thinking enabled with budget: ${thinkingConfig.thinkingBudget}`); + googleRequest.generationConfig.thinkingConfig = thinkingConfig; + } + } + + // Convert function tools and built-in Google Search tools. + if (tools && tools.length > 0) { + const functionDeclarations = []; + let hasGoogleSearch = false; + let hasGoogleSearchRetrieval = false; + + tools.forEach((tool, idx) => { + const name = tool.name || tool.function?.name || tool.custom?.name || `tool-${idx}`; + const normalizedName = String(name).toLowerCase().replace(/[_-]/g, ''); + const normalizedType = String(tool.type || '').toLowerCase().replace(/[_-]/g, ''); + + if (tool.googleSearch !== undefined + || normalizedName === 'googlesearch' + || ['googlesearch', 'websearch', 'websearchpreview'].includes(normalizedType)) { + hasGoogleSearch = true; + return; + } + if (tool.googleSearchRetrieval !== undefined || normalizedName === 'googlesearchretrieval') { + hasGoogleSearchRetrieval = true; + return; + } + + const description = tool.description || tool.function?.description || tool.custom?.description || ''; + const schema = tool.input_schema + || tool.function?.input_schema + || tool.function?.parameters + || tool.custom?.input_schema + || tool.parameters + || { type: 'object' }; + + let parameters = sanitizeSchema(schema); + parameters = cleanSchema(parameters); + + functionDeclarations.push({ + name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64), + description, + parameters + }); + }); + + googleRequest.tools = []; + if (functionDeclarations.length > 0) { + googleRequest.tools.push({ functionDeclarations }); + } + if (hasGoogleSearch) { + googleRequest.tools.push({ googleSearch: {} }); + } + if (hasGoogleSearchRetrieval) { + googleRequest.tools.push({ googleSearchRetrieval: {} }); + } + + if (tool_choice?.type === 'none') { + delete googleRequest.tools; + } else if (googleRequest.tools.length > 0) { + if (functionDeclarations.length > 0) { + const functionCallingConfig = {}; + if (tool_choice?.type === 'any') { + functionCallingConfig.mode = 'ANY'; + } else if (tool_choice?.type === 'tool' && tool_choice.name) { + functionCallingConfig.mode = 'ANY'; + functionCallingConfig.allowedFunctionNames = [tool_choice.name]; + } else if (isClaudeModel) { + functionCallingConfig.mode = 'VALIDATED'; + } else { + functionCallingConfig.mode = 'AUTO'; + } + + googleRequest.toolConfig = { functionCallingConfig }; + } + logger.debug(`[RequestConverter] Tools: ${JSON.stringify(googleRequest.tools).substring(0, 300)}`); + } + } + + // Cap max tokens for Gemini models + if (isGeminiModel && googleRequest.generationConfig.maxOutputTokens > GEMINI_MAX_OUTPUT_TOKENS) { + logger.debug(`[RequestConverter] Capping Gemini max_tokens from ${googleRequest.generationConfig.maxOutputTokens} to ${GEMINI_MAX_OUTPUT_TOKENS}`); + googleRequest.generationConfig.maxOutputTokens = GEMINI_MAX_OUTPUT_TOKENS; + } + + return googleRequest; +} diff --git a/src/format/response-converter.js b/src/format/response-converter.js new file mode 100755 index 0000000000000000000000000000000000000000..082825ca9105644c7d8e8392f591ba743615b168 --- /dev/null +++ b/src/format/response-converter.js @@ -0,0 +1,120 @@ +/** + * Response Converter + * Converts Google Generative AI responses to Anthropic Messages API format + */ + +import crypto from 'crypto'; +import { MIN_SIGNATURE_LENGTH, getModelFamily } from '../constants.js'; +import { cacheSignature, cacheThinkingSignature } from './signature-cache.js'; + +/** + * Convert Google Generative AI response to Anthropic Messages API format + * + * @param {Object} googleResponse - Google format response (the inner response object) + * @param {string} model - The model name used + * @returns {Object} Anthropic format response + */ +export function convertGoogleToAnthropic(googleResponse, model) { + // Handle the response wrapper + const response = googleResponse.response || googleResponse; + + const candidates = response.candidates || []; + const firstCandidate = candidates[0] || {}; + const content = firstCandidate.content || {}; + const parts = content.parts || []; + + // Convert parts to Anthropic content blocks + const anthropicContent = []; + let hasToolCalls = false; + + for (const part of parts) { + if (part.text !== undefined) { + // Handle thinking blocks + if (part.thought === true) { + const signature = part.thoughtSignature || ''; + + // Cache thinking signature with model family for cross-model compatibility + if (signature && signature.length >= MIN_SIGNATURE_LENGTH) { + const modelFamily = getModelFamily(model); + cacheThinkingSignature(signature, modelFamily); + } + + // Include thinking blocks in the response for Claude Code + anthropicContent.push({ + type: 'thinking', + thinking: part.text, + signature: signature + }); + } else { + anthropicContent.push({ + type: 'text', + text: part.text + }); + } + } else if (part.functionCall) { + // Convert functionCall to tool_use + // Use the id from the response if available, otherwise generate one + const toolId = part.functionCall.id || `toolu_${crypto.randomBytes(12).toString('hex')}`; + const toolUseBlock = { + type: 'tool_use', + id: toolId, + name: part.functionCall.name, + input: part.functionCall.args || {} + }; + + // For Gemini 3+, include thoughtSignature from the part level + if (part.thoughtSignature && part.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) { + toolUseBlock.thoughtSignature = part.thoughtSignature; + // Cache for future requests (Claude Code may strip this field) + cacheSignature(toolId, part.thoughtSignature); + } + + anthropicContent.push(toolUseBlock); + hasToolCalls = true; + } else if (part.inlineData) { + // Handle image content from Google format + anthropicContent.push({ + type: 'image', + source: { + type: 'base64', + media_type: part.inlineData.mimeType, + data: part.inlineData.data + } + }); + } + } + + // Determine stop reason + const finishReason = firstCandidate.finishReason; + let stopReason = 'end_turn'; + if (finishReason === 'STOP') { + stopReason = 'end_turn'; + } else if (finishReason === 'MAX_TOKENS') { + stopReason = 'max_tokens'; + } else if (finishReason === 'TOOL_USE' || hasToolCalls) { + stopReason = 'tool_use'; + } + + // Extract usage metadata + // Note: Antigravity's promptTokenCount is the TOTAL (includes cached), + // but Anthropic's input_tokens excludes cached. We subtract to match. + const usageMetadata = response.usageMetadata || {}; + const promptTokens = usageMetadata.promptTokenCount || 0; + const cachedTokens = usageMetadata.cachedContentTokenCount || 0; + + return { + id: `msg_${crypto.randomBytes(16).toString('hex')}`, + type: 'message', + role: 'assistant', + content: anthropicContent.length > 0 ? anthropicContent : [{ type: 'text', text: '' }], + model: model, + stop_reason: stopReason, + stop_sequence: null, + usage: { + input_tokens: promptTokens - cachedTokens, + output_tokens: usageMetadata.candidatesTokenCount || 0, + cache_read_input_tokens: cachedTokens, + cache_creation_input_tokens: 0 + } + }; +} diff --git a/src/format/schema-sanitizer.js b/src/format/schema-sanitizer.js new file mode 100755 index 0000000000000000000000000000000000000000..b6a8569a9d5d90a954fd9e203e4ac1376fff0463 --- /dev/null +++ b/src/format/schema-sanitizer.js @@ -0,0 +1,673 @@ +/** + * Schema Sanitizer + * Cleans and transforms JSON schemas for Gemini/Antigravity API compatibility + * + * Uses a multi-phase pipeline matching opencode-antigravity-auth approach: + * - Phase 1: Convert $refs to description hints + * - Phase 2a: Merge allOf schemas + * - Phase 2b: Flatten anyOf/oneOf (select best option) + * - Phase 2c: Flatten type arrays + update required for nullable + * - Phase 3: Remove unsupported keywords + * - Phase 4: Final cleanup (required array validation) + */ + +/** + * Append a hint to a schema's description field. + * Format: "existing (hint)" or just "hint" if no existing description. + * + * @param {Object} schema - Schema object to modify + * @param {string} hint - Hint text to append + * @returns {Object} Modified schema with appended description + */ +function appendDescriptionHint(schema, hint) { + if (!schema || typeof schema !== 'object') return schema; + const result = { ...schema }; + result.description = result.description + ? `${result.description} (${hint})` + : hint; + return result; +} + +/** + * Score a schema option for anyOf/oneOf selection. + * Higher scores = more preferred schemas. + * + * @param {Object} schema - Schema option to score + * @returns {number} Score (0-3) + */ +function scoreSchemaOption(schema) { + if (!schema || typeof schema !== 'object') return 0; + + // Score 3: Object types with properties (most informative) + if (schema.type === 'object' || schema.properties) return 3; + + // Score 2: Array types with items + if (schema.type === 'array' || schema.items) return 2; + + // Score 1: Any other non-null type + if (schema.type && schema.type !== 'null') return 1; + + // Score 0: Null or no type + return 0; +} + +/** + * Convert $ref references to description hints. + * Replaces { $ref: "#/$defs/Foo" } with { type: "object", description: "See: Foo" } + * + * @param {Object} schema - Schema to process + * @returns {Object} Schema with refs converted to hints + */ +function convertRefsToHints(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(convertRefsToHints); + + const result = { ...schema }; + + // Handle $ref at this level + if (result.$ref && typeof result.$ref === 'string') { + // Extract definition name from ref path (e.g., "#/$defs/Foo" -> "Foo") + const parts = result.$ref.split('/'); + const defName = parts[parts.length - 1] || 'unknown'; + const hint = `See: ${defName}`; + + // Merge with existing description if present + const description = result.description + ? `${result.description} (${hint})` + : hint; + + // Replace with object type and hint + return { type: 'object', description }; + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + result.properties = {}; + for (const [key, value] of Object.entries(schema.properties)) { + result.properties[key] = convertRefsToHints(value); + } + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(convertRefsToHints); + } else if (typeof result.items === 'object') { + result.items = convertRefsToHints(result.items); + } + } + + // Recursively process anyOf/oneOf/allOf + for (const key of ['anyOf', 'oneOf', 'allOf']) { + if (Array.isArray(result[key])) { + result[key] = result[key].map(convertRefsToHints); + } + } + + return result; +} + +/** + * Merge all schemas in an allOf array into a single schema. + * Properties and required arrays are merged; other fields use first occurrence. + * + * @param {Object} schema - Schema with potential allOf to merge + * @returns {Object} Schema with allOf merged + */ +function mergeAllOf(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(mergeAllOf); + + let result = { ...schema }; + + // Process allOf if present + if (Array.isArray(result.allOf) && result.allOf.length > 0) { + const mergedProperties = {}; + const mergedRequired = new Set(); + const otherFields = {}; + + for (const subSchema of result.allOf) { + if (!subSchema || typeof subSchema !== 'object') continue; + + // Merge properties (later overrides earlier) + if (subSchema.properties) { + for (const [key, value] of Object.entries(subSchema.properties)) { + mergedProperties[key] = value; + } + } + + // Union required arrays + if (Array.isArray(subSchema.required)) { + for (const req of subSchema.required) { + mergedRequired.add(req); + } + } + + // Copy other fields (first occurrence wins) + for (const [key, value] of Object.entries(subSchema)) { + if (key !== 'properties' && key !== 'required' && !(key in otherFields)) { + otherFields[key] = value; + } + } + } + + // Apply merged content + delete result.allOf; + + // Merge other fields first (parent takes precedence) + for (const [key, value] of Object.entries(otherFields)) { + if (!(key in result)) { + result[key] = value; + } + } + + // Merge properties (allOf properties override parent for same keys) + if (Object.keys(mergedProperties).length > 0) { + result.properties = { ...mergedProperties, ...(result.properties || {}) }; + } + + // Merge required + if (mergedRequired.size > 0) { + const parentRequired = Array.isArray(result.required) ? result.required : []; + result.required = [...new Set([...mergedRequired, ...parentRequired])]; + } + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = mergeAllOf(value); + } + result.properties = newProps; + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(mergeAllOf); + } else if (typeof result.items === 'object') { + result.items = mergeAllOf(result.items); + } + } + + return result; +} + +/** + * Flatten anyOf/oneOf by selecting the best option based on scoring. + * Adds type hints to description when multiple types existed. + * + * @param {Object} schema - Schema with potential anyOf/oneOf + * @returns {Object} Flattened schema + */ +function flattenAnyOfOneOf(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(flattenAnyOfOneOf); + + let result = { ...schema }; + + // Handle anyOf or oneOf + for (const unionKey of ['anyOf', 'oneOf']) { + if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) { + const options = result[unionKey]; + + // Collect type names for hint + const typeNames = []; + let bestOption = null; + let bestScore = -1; + + for (const option of options) { + if (!option || typeof option !== 'object') continue; + + // Collect type name + const typeName = option.type || (option.properties ? 'object' : null); + if (typeName && typeName !== 'null') { + typeNames.push(typeName); + } + + // Score and track best option + const score = scoreSchemaOption(option); + if (score > bestScore) { + bestScore = score; + bestOption = option; + } + } + + // Remove the union key + delete result[unionKey]; + + // Merge best option into result + if (bestOption) { + // Preserve parent description + const parentDescription = result.description; + + // Recursively flatten the best option + const flattenedOption = flattenAnyOfOneOf(bestOption); + + // Merge fields from selected option + for (const [key, value] of Object.entries(flattenedOption)) { + if (key === 'description') { + // Merge descriptions if different + if (value && value !== parentDescription) { + result.description = parentDescription + ? `${parentDescription} (${value})` + : value; + } + } else if (!(key in result) || key === 'type' || key === 'properties' || key === 'items') { + result[key] = value; + } + } + + // Add type hint if multiple types existed + if (typeNames.length > 1) { + const uniqueTypes = [...new Set(typeNames)]; + result = appendDescriptionHint(result, `Accepts: ${uniqueTypes.join(' | ')}`); + } + } + } + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = flattenAnyOfOneOf(value); + } + result.properties = newProps; + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(flattenAnyOfOneOf); + } else if (typeof result.items === 'object') { + result.items = flattenAnyOfOneOf(result.items); + } + } + + return result; +} + +// ============================================================================ +// Enhanced Schema Hints (for preserving semantic information) +// ============================================================================ + +/** + * Add hints for enum values (if ≤10 values). + * This preserves enum information in the description since Gemini + * may not fully support enums in all cases. + * + * @param {Object} schema - Schema to process + * @returns {Object} Schema with enum hints added to description + */ +function addEnumHints(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(addEnumHints); + + let result = { ...schema }; + + // Add enum hint if present and reasonable size + if (Array.isArray(result.enum) && result.enum.length > 1 && result.enum.length <= 10) { + const vals = result.enum.map(v => String(v)).join(', '); + result = appendDescriptionHint(result, `Allowed: ${vals}`); + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = addEnumHints(value); + } + result.properties = newProps; + } + + // Recursively process items + if (result.items) { + result.items = Array.isArray(result.items) + ? result.items.map(addEnumHints) + : addEnumHints(result.items); + } + + return result; +} + +/** + * Add hints for additionalProperties: false. + * This informs the model that extra properties are not allowed. + * + * @param {Object} schema - Schema to process + * @returns {Object} Schema with additionalProperties hints added + */ +function addAdditionalPropertiesHints(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(addAdditionalPropertiesHints); + + let result = { ...schema }; + + if (result.additionalProperties === false) { + result = appendDescriptionHint(result, 'No extra properties allowed'); + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = addAdditionalPropertiesHints(value); + } + result.properties = newProps; + } + + // Recursively process items + if (result.items) { + result.items = Array.isArray(result.items) + ? result.items.map(addAdditionalPropertiesHints) + : addAdditionalPropertiesHints(result.items); + } + + return result; +} + +/** + * Move unsupported constraints to description hints. + * This preserves constraint information that would otherwise be lost + * when we strip unsupported keywords. + * + * @param {Object} schema - Schema to process + * @returns {Object} Schema with constraint hints added to description + */ +function moveConstraintsToDescription(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(moveConstraintsToDescription); + + const CONSTRAINTS = ['minLength', 'maxLength', 'pattern', 'minimum', 'maximum', + 'minItems', 'maxItems', 'format']; + + let result = { ...schema }; + + for (const constraint of CONSTRAINTS) { + if (result[constraint] !== undefined && typeof result[constraint] !== 'object') { + result = appendDescriptionHint(result, `${constraint}: ${result[constraint]}`); + } + } + + // Recursively process properties + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = moveConstraintsToDescription(value); + } + result.properties = newProps; + } + + // Recursively process items + if (result.items) { + result.items = Array.isArray(result.items) + ? result.items.map(moveConstraintsToDescription) + : moveConstraintsToDescription(result.items); + } + + return result; +} + +/** + * Flatten array type fields and track nullable properties. + * Converts { type: ["string", "null"] } to { type: "string" } with nullable hint. + * + * @param {Object} schema - Schema to process + * @param {Set} nullableProps - Set to collect nullable property names (mutated) + * @param {string} currentPropName - Current property name (for tracking) + * @returns {Object} Flattened schema + */ +function flattenTypeArrays(schema, nullableProps = null, currentPropName = null) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(s => flattenTypeArrays(s, nullableProps)); + + let result = { ...schema }; + + // Handle array type fields + if (Array.isArray(result.type)) { + const types = result.type; + const hasNull = types.includes('null'); + const nonNullTypes = types.filter(t => t !== 'null' && t); + + // Select first non-null type, or 'string' as fallback + const firstType = nonNullTypes.length > 0 ? nonNullTypes[0] : 'string'; + result.type = firstType; + + // Add hint for multiple types + if (nonNullTypes.length > 1) { + result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(' | ')}`); + } + + // Track nullable and add hint + if (hasNull) { + result = appendDescriptionHint(result, 'nullable'); + // Track this property as nullable for required array update + if (nullableProps && currentPropName) { + nullableProps.add(currentPropName); + } + } + } + + // Recursively process properties, tracking nullable ones + if (result.properties && typeof result.properties === 'object') { + const childNullableProps = new Set(); + const newProps = {}; + + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = flattenTypeArrays(value, childNullableProps, key); + } + result.properties = newProps; + + // Remove nullable properties from required array + if (Array.isArray(result.required) && childNullableProps.size > 0) { + result.required = result.required.filter(prop => !childNullableProps.has(prop)); + if (result.required.length === 0) { + delete result.required; + } + } + } + + // Recursively process items + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(item => flattenTypeArrays(item, nullableProps)); + } else if (typeof result.items === 'object') { + result.items = flattenTypeArrays(result.items, nullableProps); + } + } + + return result; +} + +/** + * Sanitize JSON Schema for Antigravity API compatibility. + * Uses allowlist approach - only permit known-safe JSON Schema features. + * Converts "const" to equivalent "enum" for compatibility. + * Generates placeholder schema for empty tool schemas. + */ +export function sanitizeSchema(schema) { + if (!schema || typeof schema !== 'object') { + // Empty/missing schema - generate placeholder with reason property + return { + type: 'object', + properties: { + reason: { + type: 'string', + description: 'Reason for calling this tool' + } + }, + required: ['reason'] + }; + } + + // Allowlist of permitted JSON Schema fields + const ALLOWED_FIELDS = new Set([ + 'type', + 'description', + 'properties', + 'required', + 'items', + 'enum', + 'title' + ]); + + const sanitized = {}; + + for (const [key, value] of Object.entries(schema)) { + // Convert "const" to "enum" for compatibility + if (key === 'const') { + sanitized.enum = [value]; + continue; + } + + // Skip fields not in allowlist + if (!ALLOWED_FIELDS.has(key)) { + continue; + } + + if (key === 'properties' && value && typeof value === 'object') { + sanitized.properties = {}; + for (const [propKey, propValue] of Object.entries(value)) { + sanitized.properties[propKey] = sanitizeSchema(propValue); + } + } else if (key === 'items' && value && typeof value === 'object') { + if (Array.isArray(value)) { + sanitized.items = value.map(item => sanitizeSchema(item)); + } else { + sanitized.items = sanitizeSchema(value); + } + } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + sanitized[key] = sanitizeSchema(value); + } else { + sanitized[key] = value; + } + } + + // Ensure we have at least a type + if (!sanitized.type) { + sanitized.type = 'object'; + } + + // If object type with no properties, add placeholder + if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) { + sanitized.properties = { + reason: { + type: 'string', + description: 'Reason for calling this tool' + } + }; + sanitized.required = ['reason']; + } + + return sanitized; +} + +/** + * Convert JSON Schema type names to Google's Protobuf-style uppercase type names. + * Google's Generative AI API expects uppercase types: STRING, OBJECT, ARRAY, etc. + * + * @param {string} type - JSON Schema type name (lowercase) + * @returns {string} Google-format type name (uppercase) + */ +function toGoogleType(type) { + if (!type || typeof type !== 'string') return type; + const typeMap = { + 'string': 'STRING', + 'number': 'NUMBER', + 'integer': 'INTEGER', + 'boolean': 'BOOLEAN', + 'array': 'ARRAY', + 'object': 'OBJECT', + 'null': 'STRING' // Fallback for null type + }; + return typeMap[type.toLowerCase()] || type.toUpperCase(); +} + +/** + * Cleans JSON schema for Gemini API compatibility. + * Uses a multi-phase pipeline matching opencode-antigravity-auth approach. + * + * @param {Object} schema - The JSON schema to clean + * @returns {Object} Cleaned schema safe for Gemini API + */ +export function cleanSchema(schema) { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(cleanSchema); + + // Phase 1: Convert $refs to hints + let result = convertRefsToHints(schema); + + // Phase 1b: Add enum hints (preserves enum info in description) + result = addEnumHints(result); + + // Phase 1c: Add additionalProperties hints + result = addAdditionalPropertiesHints(result); + + // Phase 1d: Move constraints to description (before they get stripped) + result = moveConstraintsToDescription(result); + + // Phase 2a: Merge allOf schemas + result = mergeAllOf(result); + + // Phase 2b: Flatten anyOf/oneOf + result = flattenAnyOfOneOf(result); + + // Phase 2c: Flatten type arrays and update required for nullable + result = flattenTypeArrays(result); + + // Phase 3: Remove unsupported keywords + const unsupported = [ + 'additionalProperties', 'default', '$schema', '$defs', + 'definitions', '$ref', '$id', '$comment', 'title', + 'minLength', 'maxLength', 'pattern', 'format', + 'minItems', 'maxItems', 'examples', 'allOf', 'anyOf', 'oneOf' + ]; + + for (const key of unsupported) { + delete result[key]; + } + + // Check for unsupported 'format' in string types + if (result.type === 'string' && result.format) { + const allowed = ['enum', 'date-time']; + if (!allowed.includes(result.format)) { + delete result.format; + } + } + + // Phase 4: Final cleanup - recursively clean nested schemas and validate required + if (result.properties && typeof result.properties === 'object') { + const newProps = {}; + for (const [key, value] of Object.entries(result.properties)) { + newProps[key] = cleanSchema(value); + } + result.properties = newProps; + } + + if (result.items) { + if (Array.isArray(result.items)) { + result.items = result.items.map(cleanSchema); + } else if (typeof result.items === 'object') { + result.items = cleanSchema(result.items); + } + } + + // Validate that required array only contains properties that exist + if (result.required && Array.isArray(result.required) && result.properties) { + const definedProps = new Set(Object.keys(result.properties)); + result.required = result.required.filter(prop => definedProps.has(prop)); + if (result.required.length === 0) { + delete result.required; + } + } + + // Phase 5: Convert type to Google's uppercase format (STRING, OBJECT, ARRAY, etc.) + // Only convert at current level - nested types already converted by recursive cleanSchema calls + if (result.type && typeof result.type === 'string') { + result.type = toGoogleType(result.type); + } + + return result; +} diff --git a/src/format/signature-cache.js b/src/format/signature-cache.js new file mode 100755 index 0000000000000000000000000000000000000000..22040ef2ffa4d66f5b46dcd653bc54ea1992eb18 --- /dev/null +++ b/src/format/signature-cache.js @@ -0,0 +1,88 @@ +/** + * Signature Cache + * In-memory cache for Gemini thoughtSignatures + * + * Gemini models require thoughtSignature on tool calls, but Claude Code + * strips non-standard fields. This cache stores signatures by tool_use_id + * so they can be restored in subsequent requests. + * + * Also caches thinking block signatures with model family for cross-model + * compatibility checking. + */ + +import { GEMINI_SIGNATURE_CACHE_TTL_MS, MIN_SIGNATURE_LENGTH } from '../constants.js'; + +const signatureCache = new Map(); +const thinkingSignatureCache = new Map(); + +/** + * Store a signature for a tool_use_id + * @param {string} toolUseId - The tool use ID + * @param {string} signature - The thoughtSignature to cache + */ +export function cacheSignature(toolUseId, signature) { + if (!toolUseId || !signature) return; + signatureCache.set(toolUseId, { + signature, + timestamp: Date.now() + }); +} + +/** + * Get a cached signature for a tool_use_id + * @param {string} toolUseId - The tool use ID + * @returns {string|null} The cached signature or null if not found/expired + */ +export function getCachedSignature(toolUseId) { + if (!toolUseId) return null; + const entry = signatureCache.get(toolUseId); + if (!entry) return null; + + // Check TTL + if (Date.now() - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) { + signatureCache.delete(toolUseId); + return null; + } + + return entry.signature; +} + +/** + * Cache a thinking block signature with its model family + * @param {string} signature - The thinking signature to cache + * @param {string} modelFamily - The model family ('claude' or 'gemini') + */ +export function cacheThinkingSignature(signature, modelFamily) { + if (!signature || signature.length < MIN_SIGNATURE_LENGTH) return; + thinkingSignatureCache.set(signature, { + modelFamily, + timestamp: Date.now() + }); +} + +/** + * Get the cached model family for a thinking signature + * @param {string} signature - The signature to look up + * @returns {string|null} 'claude', 'gemini', or null if not found/expired + */ +export function getCachedSignatureFamily(signature) { + if (!signature) return null; + const entry = thinkingSignatureCache.get(signature); + if (!entry) return null; + + // Check TTL + if (Date.now() - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) { + thinkingSignatureCache.delete(signature); + return null; + } + + return entry.modelFamily; +} + +/** + * Clear all entries from the thinking signature cache. + * Used for testing cold cache scenarios. + */ +export function clearThinkingSignatureCache() { + thinkingSignatureCache.clear(); +} diff --git a/src/format/thinking-utils.js b/src/format/thinking-utils.js new file mode 100755 index 0000000000000000000000000000000000000000..358879419d02d36cd641607339e112c4520ffcf7 --- /dev/null +++ b/src/format/thinking-utils.js @@ -0,0 +1,698 @@ +/** + * Thinking Block Utilities + * Handles thinking block processing, validation, and filtering + */ + +import { MIN_SIGNATURE_LENGTH } from '../constants.js'; +import { getCachedSignatureFamily } from './signature-cache.js'; +import { logger } from '../utils/logger.js'; + +// ============================================================================ +// Gemini Thinking Budget Limits (Issue #289) +// ============================================================================ + +// Max thinking budget per Gemini model version +// Gemini 2.5 Flash: max 24,576 (API error: "supported values are integers from 1 to 24576") +const GEMINI_THINKING_BUDGET_LIMITS = { + '2.5': 24576, +}; +const GEMINI_DEFAULT_THINKING_BUDGET = 16000; +const GEMINI_DEFAULT_THINKING_BUDGET_LIMIT = 128000; + +/** + * Clamp thinking budget to the maximum supported by a Gemini model. + * Different Gemini versions have different limits (e.g., 2.5 Flash max is 24,576). + * + * @param {string} modelName - The Gemini model name + * @param {number|undefined} budget - Requested thinking budget from the client + * @returns {number} Clamped thinking budget + */ +export function clampGeminiThinkingBudget(modelName, budget) { + const requestedBudget = budget || GEMINI_DEFAULT_THINKING_BUDGET; + const lower = (modelName || '').toLowerCase(); + + // Extract version like "2.5" or "3" from "gemini-2.5-flash-thinking" + const versionMatch = lower.match(/gemini-(\d+(?:\.\d+)?)/); + let maxBudget = GEMINI_DEFAULT_THINKING_BUDGET_LIMIT; + + if (versionMatch) { + const version = versionMatch[1]; + // Check exact version (e.g., "2.5") + if (GEMINI_THINKING_BUDGET_LIMITS[version]) { + maxBudget = GEMINI_THINKING_BUDGET_LIMITS[version]; + } else { + // Check major version (e.g., "2" for "2.5") + const major = version.split('.')[0]; + if (GEMINI_THINKING_BUDGET_LIMITS[major]) { + maxBudget = GEMINI_THINKING_BUDGET_LIMITS[major]; + } + } + } + + if (requestedBudget > maxBudget) { + logger.debug(`[ThinkingUtils] Clamping Gemini thinking budget from ${requestedBudget} to ${maxBudget} for ${modelName}`); + return maxBudget; + } + + return requestedBudget; +} + +// ============================================================================ +// Cache Control Cleaning (Issue #189) +// ============================================================================ + +/** + * Remove cache_control fields from all content blocks in messages. + * This is a critical fix for Issue #189 where Claude Code CLI sends cache_control + * fields that the Cloud Code API rejects with "Extra inputs are not permitted". + * + * Inspired by Antigravity-Manager's clean_cache_control_from_messages() approach, + * this function proactively strips cache_control from ALL block types at the + * entry point of the conversion pipeline. + * + * @param {Array} messages - Array of messages in Anthropic format + * @returns {Array} Messages with cache_control fields removed + */ +export function cleanCacheControl(messages) { + if (!Array.isArray(messages)) return messages; + + let removedCount = 0; + + const cleaned = messages.map(message => { + if (!message || typeof message !== 'object') return message; + + // Handle string content (no cache_control possible) + if (typeof message.content === 'string') return message; + + // Handle array content + if (!Array.isArray(message.content)) return message; + + const cleanedContent = message.content.map(block => { + if (!block || typeof block !== 'object') return block; + + // Check if cache_control exists before destructuring + if (block.cache_control === undefined) return block; + + // Create a shallow copy without cache_control + const { cache_control, ...cleanBlock } = block; + removedCount++; + + return cleanBlock; + }); + + return { + ...message, + content: cleanedContent + }; + }); + + if (removedCount > 0) { + logger.debug(`[ThinkingUtils] Removed cache_control from ${removedCount} block(s)`); + } + + return cleaned; +} + +/** + * Check if a part is a thinking block + * @param {Object} part - Content part to check + * @returns {boolean} True if the part is a thinking block + */ +function isThinkingPart(part) { + return part.type === 'thinking' || + part.type === 'redacted_thinking' || + part.thinking !== undefined || + part.thought === true; +} + +/** + * Check if a thinking part has a valid signature (>= MIN_SIGNATURE_LENGTH chars) + */ +function hasValidSignature(part) { + const signature = part.thought === true ? part.thoughtSignature : part.signature; + return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH; +} + +/** + * Check if conversation history contains Gemini-style messages. + * Gemini puts thoughtSignature on tool_use blocks, Claude puts signature on thinking blocks. + * @param {Array} messages - Array of messages + * @returns {boolean} True if any tool_use has thoughtSignature (Gemini pattern) + */ +export function hasGeminiHistory(messages) { + return messages.some(msg => + Array.isArray(msg.content) && + msg.content.some(block => + block.type === 'tool_use' && block.thoughtSignature !== undefined + ) + ); +} + +/** + * Check if conversation has unsigned thinking blocks that will be dropped. + * These cause "Expected thinking but found text" errors. + * @param {Array} messages - Array of messages + * @returns {boolean} True if any assistant message has unsigned thinking blocks + */ +export function hasUnsignedThinkingBlocks(messages) { + return messages.some(msg => { + if (msg.role !== 'assistant' && msg.role !== 'model') return false; + if (!Array.isArray(msg.content)) return false; + return msg.content.some(block => + isThinkingPart(block) && !hasValidSignature(block) + ); + }); +} + +/** + * Sanitize a thinking part by keeping only allowed fields + */ +function sanitizeThinkingPart(part) { + // Gemini-style thought blocks: { thought: true, text, thoughtSignature } + if (part.thought === true) { + const sanitized = { thought: true }; + if (part.text !== undefined) sanitized.text = part.text; + if (part.thoughtSignature !== undefined) sanitized.thoughtSignature = part.thoughtSignature; + return sanitized; + } + + // Anthropic-style thinking blocks: { type: "thinking", thinking, signature } + if (part.type === 'thinking' || part.thinking !== undefined) { + const sanitized = { type: 'thinking' }; + if (part.thinking !== undefined) sanitized.thinking = part.thinking; + if (part.signature !== undefined) sanitized.signature = part.signature; + return sanitized; + } + + return part; +} + +/** + * Sanitize a thinking block by removing extra fields like cache_control. + * Only keeps: type, thinking, signature (for thinking) or type, data (for redacted_thinking) + */ +function sanitizeAnthropicThinkingBlock(block) { + if (!block) return block; + + if (block.type === 'thinking') { + const sanitized = { type: 'thinking' }; + if (block.thinking !== undefined) sanitized.thinking = block.thinking; + if (block.signature !== undefined) sanitized.signature = block.signature; + return sanitized; + } + + if (block.type === 'redacted_thinking') { + const sanitized = { type: 'redacted_thinking' }; + if (block.data !== undefined) sanitized.data = block.data; + return sanitized; + } + + return block; +} + +/** + * Sanitize a text block by removing extra fields like cache_control. + * Only keeps: type, text + * @param {Object} block - Text block to sanitize + * @returns {Object} Sanitized text block + */ +function sanitizeTextBlock(block) { + if (!block || block.type !== 'text') return block; + + const sanitized = { type: 'text' }; + if (block.text !== undefined) sanitized.text = block.text; + return sanitized; +} + +/** + * Sanitize a tool_use block by removing extra fields like cache_control. + * Only keeps: type, id, name, input, thoughtSignature (for Gemini) + * @param {Object} block - Tool_use block to sanitize + * @returns {Object} Sanitized tool_use block + */ +function sanitizeToolUseBlock(block) { + if (!block || block.type !== 'tool_use') return block; + + const sanitized = { type: 'tool_use' }; + if (block.id !== undefined) sanitized.id = block.id; + if (block.name !== undefined) sanitized.name = block.name; + if (block.input !== undefined) sanitized.input = block.input; + // Preserve thoughtSignature for Gemini models + if (block.thoughtSignature !== undefined) sanitized.thoughtSignature = block.thoughtSignature; + return sanitized; +} + +/** + * Filter content array, keeping only thinking blocks with valid signatures. + */ +function filterContentArray(contentArray) { + const filtered = []; + + for (const item of contentArray) { + if (!item || typeof item !== 'object') { + filtered.push(item); + continue; + } + + if (!isThinkingPart(item)) { + filtered.push(item); + continue; + } + + // Keep items with valid signatures + if (hasValidSignature(item)) { + filtered.push(sanitizeThinkingPart(item)); + continue; + } + + // Drop unsigned thinking blocks + logger.debug('[ThinkingUtils] Dropping unsigned thinking block'); + } + + return filtered; +} + +/** + * Filter unsigned thinking blocks from contents (Gemini format) + * + * @param {Array<{role: string, parts: Array}>} contents - Array of content objects in Gemini format + * @returns {Array<{role: string, parts: Array}>} Filtered contents with unsigned thinking blocks removed + */ +export function filterUnsignedThinkingBlocks(contents) { + return contents.map(content => { + if (!content || typeof content !== 'object') return content; + + if (Array.isArray(content.parts)) { + return { ...content, parts: filterContentArray(content.parts) }; + } + + return content; + }); +} + +/** + * Remove trailing unsigned thinking blocks from assistant messages. + * Claude/Gemini APIs require that assistant messages don't end with unsigned thinking blocks. + * + * @param {Array} content - Array of content blocks + * @returns {Array} Content array with trailing unsigned thinking blocks removed + */ +export function removeTrailingThinkingBlocks(content) { + if (!Array.isArray(content)) return content; + if (content.length === 0) return content; + + // Work backwards from the end, removing thinking blocks + let endIndex = content.length; + for (let i = content.length - 1; i >= 0; i--) { + const block = content[i]; + if (!block || typeof block !== 'object') break; + + // Check if it's a thinking block (any format) + const isThinking = isThinkingPart(block); + + if (isThinking) { + // Check if it has a valid signature + if (!hasValidSignature(block)) { + endIndex = i; + } else { + break; // Stop at signed thinking block + } + } else { + break; // Stop at first non-thinking block + } + } + + if (endIndex < content.length) { + logger.debug('[ThinkingUtils] Removed', content.length - endIndex, 'trailing unsigned thinking blocks'); + return content.slice(0, endIndex); + } + + return content; +} + +/** + * Filter thinking blocks: keep only those with valid signatures. + * Blocks without signatures are dropped (API requires signatures). + * Also sanitizes blocks to remove extra fields like cache_control. + * + * @param {Array} content - Array of content blocks + * @returns {Array} Filtered content with only valid signed thinking blocks + */ +export function restoreThinkingSignatures(content) { + if (!Array.isArray(content)) return content; + + const originalLength = content.length; + const filtered = []; + + for (const block of content) { + if (!block || block.type !== 'thinking') { + filtered.push(block); + continue; + } + + // Keep blocks with valid signatures (>= MIN_SIGNATURE_LENGTH chars), sanitized + if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) { + filtered.push(sanitizeAnthropicThinkingBlock(block)); + } + // Unsigned thinking blocks are dropped + } + + if (filtered.length < originalLength) { + logger.debug(`[ThinkingUtils] Dropped ${originalLength - filtered.length} unsigned thinking block(s)`); + } + + return filtered; +} + +/** + * Reorder content so that: + * 1. Thinking blocks come first (required when thinking is enabled) + * 2. Text blocks come in the middle (filtering out empty/useless ones) + * 3. Tool_use blocks come at the end (required before tool_result) + * + * @param {Array} content - Array of content blocks + * @returns {Array} Reordered content array + */ +export function reorderAssistantContent(content) { + if (!Array.isArray(content)) return content; + + // Even for single-element arrays, we need to sanitize thinking blocks + if (content.length === 1) { + const block = content[0]; + if (block && (block.type === 'thinking' || block.type === 'redacted_thinking')) { + return [sanitizeAnthropicThinkingBlock(block)]; + } + return content; + } + + const thinkingBlocks = []; + const textBlocks = []; + const toolUseBlocks = []; + let droppedEmptyBlocks = 0; + + for (const block of content) { + if (!block) continue; + + if (block.type === 'thinking' || block.type === 'redacted_thinking') { + // Sanitize thinking blocks to remove cache_control and other extra fields + thinkingBlocks.push(sanitizeAnthropicThinkingBlock(block)); + } else if (block.type === 'tool_use') { + // Sanitize tool_use blocks to remove cache_control and other extra fields + toolUseBlocks.push(sanitizeToolUseBlock(block)); + } else if (block.type === 'text') { + // Only keep text blocks with meaningful content + if (block.text && block.text.trim().length > 0) { + // Sanitize text blocks to remove cache_control and other extra fields + textBlocks.push(sanitizeTextBlock(block)); + } else { + droppedEmptyBlocks++; + } + } else { + // Other block types go in the text position + textBlocks.push(block); + } + } + + if (droppedEmptyBlocks > 0) { + logger.debug(`[ThinkingUtils] Dropped ${droppedEmptyBlocks} empty text block(s)`); + } + + const reordered = [...thinkingBlocks, ...textBlocks, ...toolUseBlocks]; + + // Log only if actual reordering happened (not just filtering) + if (reordered.length === content.length) { + const originalOrder = content.map(b => b?.type || 'unknown').join(','); + const newOrder = reordered.map(b => b?.type || 'unknown').join(','); + if (originalOrder !== newOrder) { + logger.debug('[ThinkingUtils] Reordered assistant content'); + } + } + + return reordered; +} + +// ============================================================================ +// Thinking Recovery Functions +// ============================================================================ + +/** + * Check if a message has any VALID (signed) thinking blocks. + * Only counts thinking blocks that have valid signatures, not unsigned ones + * that will be dropped later. + * + * @param {Object} message - Message to check + * @returns {boolean} True if message has valid signed thinking blocks + */ +function messageHasValidThinking(message) { + const content = message.content || message.parts || []; + if (!Array.isArray(content)) return false; + return content.some(block => { + if (!isThinkingPart(block)) return false; + // Check for valid signature (Anthropic style) + if (block.signature && block.signature.length >= MIN_SIGNATURE_LENGTH) return true; + // Check for thoughtSignature (Gemini style on functionCall) + if (block.thoughtSignature && block.thoughtSignature.length >= MIN_SIGNATURE_LENGTH) return true; + return false; + }); +} + +/** + * Check if a message has tool_use blocks + * @param {Object} message - Message to check + * @returns {boolean} True if message has tool_use blocks + */ +function messageHasToolUse(message) { + const content = message.content || message.parts || []; + if (!Array.isArray(content)) return false; + return content.some(block => + block.type === 'tool_use' || block.functionCall + ); +} + +/** + * Check if a message has tool_result blocks + * @param {Object} message - Message to check + * @returns {boolean} True if message has tool_result blocks + */ +function messageHasToolResult(message) { + const content = message.content || message.parts || []; + if (!Array.isArray(content)) return false; + return content.some(block => + block.type === 'tool_result' || block.functionResponse + ); +} + +/** + * Check if message is a plain user text message (not tool_result) + * @param {Object} message - Message to check + * @returns {boolean} True if message is plain user text + */ +function isPlainUserMessage(message) { + if (message.role !== 'user') return false; + const content = message.content || message.parts || []; + if (!Array.isArray(content)) return typeof content === 'string'; + // Check if it has tool_result blocks + return !content.some(block => + block.type === 'tool_result' || block.functionResponse + ); +} + +/** + * Analyze conversation state to detect if we're in a corrupted state. + * This includes: + * 1. Tool loop: assistant has tool_use followed by tool_results (normal flow) + * 2. Interrupted tool: assistant has tool_use followed by plain user message (interrupted) + * + * @param {Array} messages - Array of messages + * @returns {Object} State object with inToolLoop, interruptedTool, turnHasThinking, etc. + */ +function analyzeConversationState(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return { inToolLoop: false, interruptedTool: false, turnHasThinking: false, toolResultCount: 0 }; + } + + // Find the last assistant message + let lastAssistantIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant' || messages[i].role === 'model') { + lastAssistantIdx = i; + break; + } + } + + if (lastAssistantIdx === -1) { + return { inToolLoop: false, interruptedTool: false, turnHasThinking: false, toolResultCount: 0 }; + } + + const lastAssistant = messages[lastAssistantIdx]; + const hasToolUse = messageHasToolUse(lastAssistant); + const hasThinking = messageHasValidThinking(lastAssistant); + + // Count trailing tool results after the assistant message + let toolResultCount = 0; + let hasPlainUserMessageAfter = false; + for (let i = lastAssistantIdx + 1; i < messages.length; i++) { + if (messageHasToolResult(messages[i])) { + toolResultCount++; + } + if (isPlainUserMessage(messages[i])) { + hasPlainUserMessageAfter = true; + } + } + + // We're in a tool loop if: assistant has tool_use AND there are tool_results after + const inToolLoop = hasToolUse && toolResultCount > 0; + + // We have an interrupted tool if: assistant has tool_use, NO tool_results, + // but there IS a plain user message after (user interrupted and sent new message) + const interruptedTool = hasToolUse && toolResultCount === 0 && hasPlainUserMessageAfter; + + return { + inToolLoop, + interruptedTool, + turnHasThinking: hasThinking, + toolResultCount, + lastAssistantIdx + }; +} + +/** + * Check if conversation needs thinking recovery. + * + * Recovery is only needed when: + * 1. We're in a tool loop or have an interrupted tool, AND + * 2. No valid thinking blocks exist in the current turn + * + * Cross-model signature compatibility is handled by stripInvalidThinkingBlocks + * during recovery (not here). + * + * @param {Array} messages - Array of messages + * @returns {boolean} True if thinking recovery is needed + */ +export function needsThinkingRecovery(messages) { + const state = analyzeConversationState(messages); + + // Recovery is only needed in tool loops or interrupted tools + if (!state.inToolLoop && !state.interruptedTool) return false; + + // Need recovery if no valid thinking blocks exist + return !state.turnHasThinking; +} + +/** + * Strip invalid or incompatible thinking blocks from messages. + * Used before injecting synthetic messages for recovery. + * Keeps valid thinking blocks to preserve context from previous turns. + * + * @param {Array} messages - Array of messages + * @param {string} targetFamily - Target model family ('claude' or 'gemini') + * @returns {Array} Messages with invalid thinking blocks removed + */ +function stripInvalidThinkingBlocks(messages, targetFamily = null) { + let strippedCount = 0; + + const result = messages.map(msg => { + const content = msg.content || msg.parts; + if (!Array.isArray(content)) return msg; + + const filtered = content.filter(block => { + // Keep non-thinking blocks + if (!isThinkingPart(block)) return true; + + // Check generic validity (has signature of sufficient length) + if (!hasValidSignature(block)) { + strippedCount++; + return false; + } + + // Check family compatibility only for Gemini targets + // Claude can validate its own signatures, so we don't drop for Claude + if (targetFamily === 'gemini') { + const signature = block.thought === true ? block.thoughtSignature : block.signature; + const signatureFamily = getCachedSignatureFamily(signature); + + // For Gemini: drop unknown or mismatched signatures + if (!signatureFamily || signatureFamily !== targetFamily) { + strippedCount++; + return false; + } + } + + return true; + }); + + // Use '.' instead of '' because claude models reject empty text parts + if (msg.content) { + return { ...msg, content: filtered.length > 0 ? filtered : [{ type: 'text', text: '.' }] }; + } else if (msg.parts) { + return { ...msg, parts: filtered.length > 0 ? filtered : [{ text: '.' }] }; + } + return msg; + }); + + if (strippedCount > 0) { + logger.debug(`[ThinkingUtils] Stripped ${strippedCount} invalid/incompatible thinking block(s)`); + } + + return result; +} + +/** + * Close tool loop by injecting synthetic messages. + * This allows the model to start a fresh turn when thinking is corrupted. + * + * When thinking blocks are stripped (no valid signatures) and we're in the + * middle of a tool loop OR have an interrupted tool, the conversation is in + * a corrupted state. This function injects synthetic messages to close the + * loop and allow the model to continue. + * + * @param {Array} messages - Array of messages + * @param {string} targetFamily - Target model family ('claude' or 'gemini') + * @returns {Array} Modified messages with synthetic messages injected + */ +export function closeToolLoopForThinking(messages, targetFamily = null) { + const state = analyzeConversationState(messages); + + // Handle neither tool loop nor interrupted tool + if (!state.inToolLoop && !state.interruptedTool) return messages; + + // Strip only invalid/incompatible thinking blocks (keep valid ones) + let modified = stripInvalidThinkingBlocks(messages, targetFamily); + + if (state.interruptedTool) { + // For interrupted tools: just strip thinking and add a synthetic assistant message + // to acknowledge the interruption before the user's new message + + // Find where to insert the synthetic message (before the plain user message) + const insertIdx = state.lastAssistantIdx + 1; + + // Insert synthetic assistant message acknowledging interruption + modified.splice(insertIdx, 0, { + role: 'assistant', + content: [{ type: 'text', text: '[Tool call was interrupted.]' }] + }); + + logger.debug('[ThinkingUtils] Applied thinking recovery for interrupted tool'); + } else if (state.inToolLoop) { + // For tool loops: add synthetic messages to close the loop + const syntheticText = state.toolResultCount === 1 + ? '[Tool execution completed.]' + : `[${state.toolResultCount} tool executions completed.]`; + + // Inject synthetic model message to complete the turn + modified.push({ + role: 'assistant', + content: [{ type: 'text', text: syntheticText }] + }); + + // Inject synthetic user message to start fresh + modified.push({ + role: 'user', + content: [{ type: 'text', text: '[Continue]' }] + }); + + logger.debug('[ThinkingUtils] Applied thinking recovery for tool loop'); + } + + return modified; +} diff --git a/src/index.js b/src/index.js new file mode 100755 index 0000000000000000000000000000000000000000..24c0bb2667c049e538760a95f87f8bdd3d7cd747 --- /dev/null +++ b/src/index.js @@ -0,0 +1,185 @@ +/** + * Antigravity Claude Proxy + * Entry point - starts the proxy server + */ + +// Initialize proxy support BEFORE any other imports that may use fetch +import './utils/proxy.js'; + +import app, { accountManager } from './server.js'; +import { DEFAULT_PORT } from './constants.js'; +import { logger } from './utils/logger.js'; +import { config } from './config.js'; +import { getStrategyLabel, STRATEGY_NAMES, DEFAULT_STRATEGY } from './account-manager/strategies/index.js'; +import { getPackageVersion } from './utils/helpers.js'; +import path from 'path'; +import os from 'os'; + +const packageVersion = getPackageVersion(); + +// Parse command line arguments +const args = process.argv.slice(2); +const isDebug = args.includes('--debug') || args.includes('--dev-mode') || process.env.DEBUG === 'true' || process.env.DEV_MODE === 'true'; +const isFallbackEnabled = args.includes('--fallback') || process.env.FALLBACK === 'true'; + +// Parse --strategy flag (format: --strategy=sticky or --strategy sticky) +let strategyOverride = null; +for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--strategy=')) { + strategyOverride = args[i].split('=')[1]; + } else if (args[i] === '--strategy' && args[i + 1]) { + strategyOverride = args[i + 1]; + } +} +// Validate strategy +if (strategyOverride && !STRATEGY_NAMES.includes(strategyOverride.toLowerCase())) { + logger.warn(`[Startup] Invalid strategy "${strategyOverride}". Valid options: ${STRATEGY_NAMES.join(', ')}. Using default.`); + strategyOverride = null; +} + +// Initialize logger and devMode +logger.setDebug(isDebug); + +if (isDebug) { + config.devMode = true; + config.debug = true; + logger.debug('Developer mode enabled'); +} + +if (isFallbackEnabled) { + logger.info('Model fallback mode enabled'); +} + +if (!config.apiKey) { + logger.error('[Startup] PROXY_API_KEY is not set. All /v1/* requests will be rejected until it is configured.'); +} + +// Export fallback flag for server to use +export const FALLBACK_ENABLED = isFallbackEnabled; + +const PORT = process.env.PORT || DEFAULT_PORT; +const HOST = process.env.HOST || '0.0.0.0'; + +if (process.env.HOST) { + logger.info(`[Startup] Using HOST environment variable: ${process.env.HOST}`); +} + +// Home directory for account storage +const HOME_DIR = os.homedir(); +const CONFIG_DIR = path.join(HOME_DIR, '.antigravity-claude-proxy'); + +const server = app.listen(PORT, HOST, () => { + // Get actual bound address + const address = server.address(); + const boundHost = typeof address === 'string' ? address : address.address; + const boundPort = typeof address === 'string' ? null : address.port; + + // Clear console for a clean start + console.clear(); + + const border = '║'; + // align for 2-space indent (60 chars), align4 for 4-space indent (58 chars) + const align = (text) => text + ' '.repeat(Math.max(0, 60 - text.length)); + const align4 = (text) => text + ' '.repeat(Math.max(0, 58 - text.length)); + + // Build Control section dynamically + const strategyOptions = `(${STRATEGY_NAMES.join('/')})`; + const strategyLine2 = ' ' + strategyOptions; + let controlSection = '║ Control: ║\n'; + controlSection += '║ --strategy= Set account selection strategy ║\n'; + controlSection += `${border} ${align(strategyLine2)}${border}\n`; + if (!isDebug) { + controlSection += '║ --dev-mode Enable developer mode ║\n'; + } + if (!isFallbackEnabled) { + controlSection += '║ --fallback Enable model fallback on quota exhaust ║\n'; + } + controlSection += '║ Ctrl+C Stop server ║'; + + // Get the strategy label (accountManager will be initialized by now) + const strategyLabel = accountManager.getStrategyLabel(); + + // Build status section - always show strategy, plus any active modes + let statusSection = '║ ║\n'; + statusSection += '║ Active Modes: ║\n'; + statusSection += `${border} ${align4(`✓ Strategy: ${strategyLabel}`)}${border}\n`; + if (isDebug) { + statusSection += '║ ✓ Developer mode enabled ║\n'; + } + if (isFallbackEnabled) { + statusSection += '║ ✓ Model fallback enabled ║\n'; + } + if (process.env.CLAUDE_CONFIG_PATH) { + statusSection += `${border} ${align4(`✓ Claude config: ${process.env.CLAUDE_CONFIG_PATH}`)}${border}\n`; + } + + const environmentSection = `║ Environment Variables: ║ +║ PORT Server port (default: 8080) ║ +║ PROXY_API_KEY OpenAI-compatible bearer key ║ +║ ACCOUNT_CONFIG_PATH Account storage path ║ +║ ENABLE_WEBUI Set false for headless mode ║ +║ HOST Bind address (default: 0.0.0.0) ║ +║ HTTP_PROXY Route requests through a proxy ║ +║ CLAUDE_CONFIG_PATH Path to .claude dir (for systemd) ║ +║ See README.md for detailed configuration examples ║` + + logger.log(` +╔══════════════════════════════════════════════════════════════╗ +║ Antigravity Claude Proxy Server v${packageVersion} ║ +╠══════════════════════════════════════════════════════════════╣ +║ ║ +${border} ${align(`Server and WebUI running at: http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`)}${border} +${border} ${align(`Bound to: ${boundHost}:${boundPort}`)}${border} +${statusSection}║ ║ +${controlSection} +║ ║ +║ Endpoints: ║ +║ POST /v1/messages - Anthropic Messages API ║ +║ POST /v1/chat/completions - OpenAI Chat Completions ║ +║ GET /v1/models - List available models ║ +║ GET /health - Health check ║ +║ GET /account-limits - Account status & quotas ║ +║ POST /refresh-token - Force token refresh ║ +║ ║ +${border} ${align(`Configuration:`)}${border} +${border} ${align4(`Storage: ${CONFIG_DIR}`)}${border} +║ ║ +║ Usage with Claude Code: ║ +${border} ${align4(`export ANTHROPIC_BASE_URL=http://localhost:${PORT}`)}${border} +${border} ${align4(`export ANTHROPIC_API_KEY=${config.apiKey || 'dummy'}`)}${border} +║ claude ║ +║ ║ +║ Add Google accounts: ║ +║ npm run accounts ║ +║ ║ +║ Prerequisites (if no accounts configured): ║ +║ - Antigravity must be running ║ +║ - Have a chat panel open in Antigravity ║ +║ ║ +${environmentSection} +╚══════════════════════════════════════════════════════════════╝ + `); + + logger.success(`Server started successfully on port ${PORT}`); + if (isDebug) { + logger.warn('Running in DEVELOPER mode - verbose logs enabled'); + } +}); + +// Graceful shutdown +const shutdown = () => { + logger.info('Shutting down server...'); + server.close(() => { + logger.success('Server stopped'); + process.exit(0); + }); + + // Force close if it takes too long + setTimeout(() => { + logger.error('Could not close connections in time, forcefully shutting down'); + process.exit(1); + }, 10000); +}; + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); \ No newline at end of file diff --git a/src/middleware/api-key-auth.js b/src/middleware/api-key-auth.js new file mode 100755 index 0000000000000000000000000000000000000000..7b5c45907e0c2fe7c80673b3f10fd89ae26bf833 --- /dev/null +++ b/src/middleware/api-key-auth.js @@ -0,0 +1,96 @@ +/** + * Shared API key and admin-access helpers. + */ + +import { config } from '../config.js'; +import { createOpenAIError } from '../format/openai-compat.js'; +import { logger } from '../utils/logger.js'; + +export function extractProvidedApiKey(req) { + const authHeader = req.headers['authorization']; + const xApiKey = req.headers['x-api-key']; + + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + if (xApiKey) { + return xApiKey; + } + return ''; +} + +export function extractWebuiPassword(req) { + return req.headers['x-webui-password'] || req.query.password || ''; +} + +export function hasValidApiKey(req) { + return Boolean(config.apiKey) && extractProvidedApiKey(req) === config.apiKey; +} + +export function hasAdminAccess(req) { + if (hasValidApiKey(req)) { + return true; + } + if (config.webuiPassword && extractWebuiPassword(req) === config.webuiPassword) { + return true; + } + return false; +} + +export function requireAdminAccess(req, res, next) { + if (hasAdminAccess(req)) { + return next(); + } + + logger.warn(`[API] Unauthorized admin request from ${req.ip} to ${req.method} ${req.originalUrl}`); + return res.status(401).json({ + type: 'error', + error: { + type: 'authentication_error', + message: 'Authentication required' + } + }); +} + +export function createV1ApiKeyMiddleware() { + return (req, res, next) => { + if (!config.apiKey) { + logger.error('[API] PROXY_API_KEY is not configured; rejecting /v1 request'); + const isOpenAIEndpoint = req.path === '/chat/completions' || req.path === '/models'; + if (isOpenAIEndpoint) { + return res.status(503).json(createOpenAIError('Proxy API key is not configured', { + type: 'api_error', + code: 'service_unavailable' + })); + } + return res.status(503).json({ + type: 'error', + error: { + type: 'api_error', + message: 'Proxy API key is not configured' + } + }); + } + + const providedKey = extractProvidedApiKey(req); + if (!providedKey || providedKey !== config.apiKey) { + logger.warn(`[API] Unauthorized request from ${req.ip}, invalid API key`); + const isOpenAIEndpoint = req.path === '/chat/completions' || req.path === '/models'; + if (isOpenAIEndpoint) { + return res.status(401).json(createOpenAIError('Invalid or missing API key', { + type: 'authentication_error', + code: 'invalid_api_key' + })); + } + return res.status(401).json({ + type: 'error', + error: { + type: 'authentication_error', + message: 'Invalid or missing API key' + } + }); + } + + next(); + }; +} diff --git a/src/modules/usage-stats.js b/src/modules/usage-stats.js new file mode 100755 index 0000000000000000000000000000000000000000..379c8ca92bdbb5b9b7df5d1c4c5dab0e1b8d2270 --- /dev/null +++ b/src/modules/usage-stats.js @@ -0,0 +1,206 @@ +import fs from 'fs'; +import path from 'path'; + +import { USAGE_HISTORY_PATH } from '../constants.js'; +import { logger } from '../utils/logger.js'; + +// Persistence path +const HISTORY_FILE = USAGE_HISTORY_PATH; +const DATA_DIR = path.dirname(HISTORY_FILE); +const OLD_DATA_DIR = path.join(process.cwd(), 'data'); +const OLD_HISTORY_FILE = path.join(OLD_DATA_DIR, 'usage-history.json'); + +// In-memory storage +// Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } } +let history = {}; +let isDirty = false; + +/** + * Extract model family from model ID + * @param {string} modelId - The model identifier (e.g., "claude-opus-4-6-thinking") + * @returns {string} The family name (claude, gemini, or other) + */ +function getFamily(modelId) { + const lower = (modelId || '').toLowerCase(); + if (lower.includes('claude')) return 'claude'; + if (lower.includes('gemini')) return 'gemini'; + return 'other'; +} + +/** + * Extract short model name (without family prefix) + * @param {string} modelId - The model identifier + * @param {string} family - The model family + * @returns {string} Short model name + */ +function getShortName(modelId, family) { + if (family === 'other') return modelId; + // Remove family prefix (e.g., "claude-opus-4-6" -> "opus-4-6") + return modelId.replace(new RegExp(`^${family}-`, 'i'), ''); +} + +/** + * Ensure data directory exists and load history. + * Includes migration from legacy local data directory. + */ +function load() { + try { + // Migration logic: if old file exists and new one doesn't + if (fs.existsSync(OLD_HISTORY_FILE) && !fs.existsSync(HISTORY_FILE)) { + logger.info('[UsageStats] Migrating legacy usage data...'); + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + fs.copyFileSync(OLD_HISTORY_FILE, HISTORY_FILE); + // We keep the old file for safety initially, but could delete it + logger.info(`[UsageStats] Migration complete: ${OLD_HISTORY_FILE} -> ${HISTORY_FILE}`); + } + + if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + } + if (fs.existsSync(HISTORY_FILE)) { + const data = fs.readFileSync(HISTORY_FILE, 'utf8'); + history = JSON.parse(data); + } + } catch (err) { + logger.error('[UsageStats] Failed to load history:', err); + history = {}; + } +} + +/** + * Save history to disk + */ +function save() { + if (!isDirty) return; + try { + fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2)); + isDirty = false; + } catch (err) { + logger.error('[UsageStats] Failed to save history:', err); + } +} + +/** + * Prune old data (keep last 30 days) + */ +function prune() { + const now = new Date(); + const cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + let pruned = false; + Object.keys(history).forEach(key => { + if (new Date(key) < cutoff) { + delete history[key]; + pruned = true; + } + }); + + if (pruned) isDirty = true; +} + +/** + * Track a request by model ID using hierarchical structure + * @param {string} modelId - The specific model identifier + */ +function track(modelId) { + const now = new Date(); + // Round down to nearest hour + now.setMinutes(0, 0, 0); + const key = now.toISOString(); + + if (!history[key]) { + history[key] = { _total: 0 }; + } + + const hourData = history[key]; + const family = getFamily(modelId); + const shortName = getShortName(modelId, family); + + // Initialize family object if needed + if (!hourData[family]) { + hourData[family] = { _subtotal: 0 }; + } + + // Increment model-specific count + hourData[family][shortName] = (hourData[family][shortName] || 0) + 1; + + // Increment family subtotal + hourData[family]._subtotal = (hourData[family]._subtotal || 0) + 1; + + // Increment global total + hourData._total = (hourData._total || 0) + 1; + + isDirty = true; +} + +/** + * Setup Express Middleware + * @param {import('express').Application} app + */ +function setupMiddleware(app) { + load(); + + // Auto-save every minute + setInterval(() => { + save(); + prune(); + }, 60 * 1000); + + // Save on exit + process.on('SIGINT', () => { save(); process.exit(); }); + process.on('SIGTERM', () => { save(); process.exit(); }); + + // Request interceptor + // Track both Anthropic (/v1/messages) and OpenAI compatible (/v1/chat/completions) endpoints + const TRACKED_PATHS = ['/v1/messages', '/v1/chat/completions']; + + app.use((req, res, next) => { + if (req.method === 'POST' && TRACKED_PATHS.includes(req.path)) { + const model = req.body?.model; + if (model) { + track(model); + } + } + next(); + }); +} + +/** + * Setup API Routes + * @param {import('express').Application} app + */ +function setupRoutes(app) { + app.get('/api/stats/history', (req, res) => { + // Sort keys to ensure chronological order + const sortedKeys = Object.keys(history).sort(); + const sortedData = {}; + sortedKeys.forEach(key => { + sortedData[key] = history[key]; + }); + res.json(sortedData); + }); +} + +/** + * Get usage history data + * @returns {object} History data sorted by timestamp + */ +function getHistory() { + const sortedKeys = Object.keys(history).sort(); + const sortedData = {}; + sortedKeys.forEach(key => { + sortedData[key] = history[key]; + }); + return sortedData; +} + +export default { + setupMiddleware, + setupRoutes, + track, + getFamily, + getShortName, + getHistory +}; diff --git a/src/routes/openai-chat.js b/src/routes/openai-chat.js new file mode 100755 index 0000000000000000000000000000000000000000..1305ee201a56e40c6cb761a55af4f9125e8c9fa0 --- /dev/null +++ b/src/routes/openai-chat.js @@ -0,0 +1,117 @@ + +/** + * OpenAI-compatible POST /v1/chat/completions route. + */ + +import { sendMessage, sendMessageStream, isValidModel } from '../cloudcode/index.js'; +import { config } from '../config.js'; +import { + classifyOpenAIError, + convertAnthropicResponseToOpenAI, + convertOpenAIRequestToAnthropic +} from '../format/openai-compat.js'; +import { + convertAnthropicEventToOpenAIChunks, + createOpenAIStreamState +} from '../format/openai-stream.js'; +import { logger } from '../utils/logger.js'; + +function writeSSE(res, payload) { + res.write(`data: ${JSON.stringify(payload)}\n\n`); + if (res.flush) res.flush(); +} + +export function registerOpenAIChatRoute(app, { + accountManager, + ensureInitialized, + fallbackEnabled = false +}) { + app.post('/v1/chat/completions', async (req, res) => { + const requestedModel = req.body?.model; + + try { + await ensureInitialized(); + + const internalRequest = convertOpenAIRequestToAnthropic( + req.body, + config.modelMapping || {} + ); + const modelId = internalRequest.model; + + const { account: validationAccount } = accountManager.selectAccount(modelId); + if (validationAccount) { + const token = await accountManager.getTokenForAccount(validationAccount); + const projectId = validationAccount.subscription?.projectId || null; + const valid = await isValidModel(modelId, token, projectId); + if (!valid) { + throw new Error(`invalid_request_error: Invalid model: ${modelId}. Use /v1/models to see available models.`); + } + } + + if (accountManager.isAllRateLimited(modelId)) { + logger.warn(`[OpenAI] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`); + accountManager.resetAllRateLimits(); + } + + logger.info(`[OpenAI] Request model=${requestedModel} resolved=${modelId} stream=${internalRequest.stream}`); + + if (!internalRequest.stream) { + const anthropicResponse = await sendMessage( + internalRequest, + accountManager, + fallbackEnabled + ); + return res.json(convertAnthropicResponseToOpenAI( + anthropicResponse, + requestedModel + )); + } + + const generator = sendMessageStream( + internalRequest, + accountManager, + fallbackEnabled + ); + const firstResult = await generator.next(); + + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + const state = createOpenAIStreamState( + requestedModel, + req.body?.stream_options?.include_usage === true + ); + + if (!firstResult.done) { + for (const chunk of convertAnthropicEventToOpenAIChunks(firstResult.value, state)) { + writeSSE(res, chunk); + } + } + + for await (const event of generator) { + for (const chunk of convertAnthropicEventToOpenAIChunks(event, state)) { + writeSSE(res, chunk); + } + } + + res.write('data: [DONE]\n\n'); + return res.end(); + } catch (error) { + logger.error('[OpenAI] Request failed:', error); + const { statusCode, body } = classifyOpenAIError(error); + + if (!res.headersSent) { + return res.status(statusCode).json(body); + } + + writeSSE(res, body); + res.write('data: [DONE]\n\n'); + return res.end(); + } + }); +} + diff --git a/src/server.js b/src/server.js new file mode 100755 index 0000000000000000000000000000000000000000..ab35c42b185d33d0aea955988772d84aa1e23fce --- /dev/null +++ b/src/server.js @@ -0,0 +1,928 @@ +/** + * Express Server - Anthropic-compatible API + * Proxies to Google Cloud Code via Antigravity + * Supports multi-account load balancing + */ + +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier, isValidModel } from './cloudcode/index.js'; +import { mountWebUI } from './webui/index.js'; +import { config } from './config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +import { forceRefresh } from './auth/token-extractor.js'; +import { REQUEST_BODY_LIMIT } from './constants.js'; +import { AccountManager } from './account-manager/index.js'; +import { clearThinkingSignatureCache } from './format/signature-cache.js'; +import { appendLegacyModelAliases, resolveModelAlias } from './format/model-aliases.js'; +import { createOpenAIError } from './format/openai-compat.js'; +import { registerOpenAIChatRoute } from './routes/openai-chat.js'; +import { + createV1ApiKeyMiddleware, + hasAdminAccess, + requireAdminAccess +} from './middleware/api-key-auth.js'; +import { formatDuration } from './utils/helpers.js'; +import { logger } from './utils/logger.js'; +import { sanitizeHealthPayload } from './utils/public-sanitize.js'; +import usageStats from './modules/usage-stats.js'; + +// Parse fallback flag directly from command line args to avoid circular dependency +const args = process.argv.slice(2); +const FALLBACK_ENABLED = args.includes('--fallback') || process.env.FALLBACK === 'true'; + +// Parse --strategy flag (format: --strategy=sticky or --strategy sticky) +let STRATEGY_OVERRIDE = null; +for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--strategy=')) { + STRATEGY_OVERRIDE = args[i].split('=')[1]; + } else if (args[i] === '--strategy' && args[i + 1]) { + STRATEGY_OVERRIDE = args[i + 1]; + } +} + +const app = express(); + +// Disable x-powered-by header for security +app.disable('x-powered-by'); + +// Initialize account manager (will be fully initialized on first request or startup) +export const accountManager = new AccountManager(); + +// Track initialization status +let isInitialized = false; +let initError = null; +let initPromise = null; + +/** + * Ensure account manager is initialized (with race condition protection) + */ +async function ensureInitialized() { + if (isInitialized) return; + + // If initialization is already in progress, wait for it + if (initPromise) return initPromise; + + initPromise = (async () => { + try { + await accountManager.initialize(STRATEGY_OVERRIDE); + isInitialized = true; + const status = accountManager.getStatus(); + logger.success(`[Server] Account pool initialized: ${status.summary}`); + } catch (error) { + initError = error; + initPromise = null; // Allow retry on failure + logger.error('[Server] Failed to initialize account manager:', error.message); + throw error; + } + })(); + + return initPromise; +} + +// Middleware +app.use(cors()); +app.use(express.json({ limit: REQUEST_BODY_LIMIT })); + +// API Key authentication middleware for /v1/* endpoints (fail closed if unset) +app.use('/v1', createV1ApiKeyMiddleware()); + +// Setup usage statistics middleware +usageStats.setupMiddleware(app); + +/** + * Silent handler for Claude Code CLI root POST requests + * Claude Code sends heartbeat/event requests to POST / which we don't need + * Using app.use instead of app.post for earlier middleware interception + */ +app.use((req, res, next) => { + // Handle Claude Code event logging requests silently + if (req.method === 'POST' && req.path === '/api/event_logging/batch') { + return res.status(200).json({ status: 'ok' }); + } + // Handle Claude Code root POST requests silently + if (req.method === 'POST' && req.path === '/') { + return res.status(200).json({ status: 'ok' }); + } + next(); +}); + +/** + * Lightweight quota dashboard (always on, works on HF with ENABLE_WEBUI=false). + * Similar to the old Python proxy /dashboard page. + */ +app.get('/dashboard', (req, res) => { + res.sendFile(path.join(__dirname, '../public/quota-dashboard.html')); +}); + +// Mount WebUI unless explicitly disabled for a headless deployment. +if (process.env.ENABLE_WEBUI !== 'false') { + mountWebUI(app, __dirname, accountManager); +} else { + logger.info('[Server] WebUI disabled by ENABLE_WEBUI=false'); + app.get('/', (req, res) => { + res.json({ Status: 'API Key Proxy is running' }); + }); +} + +/** + * Parse error message to extract error type, status code, and user-friendly message + */ +function parseError(error) { + let errorType = 'api_error'; + let statusCode = 500; + let errorMessage = error.message; + + if (error.message.includes('401') || error.message.includes('UNAUTHENTICATED')) { + errorType = 'authentication_error'; + statusCode = 401; + errorMessage = 'Authentication failed. Make sure Antigravity is running with a valid token.'; + } else if (error.message.includes('429') || error.message.includes('RESOURCE_EXHAUSTED') || error.message.includes('QUOTA_EXHAUSTED')) { + errorType = 'invalid_request_error'; // Use invalid_request_error to force client to purge/stop + statusCode = 400; // Use 400 to ensure client does not retry (429 and 529 trigger retries) + + // Try to extract the quota reset time from the error + const resetMatch = error.message.match(/quota will reset after ([\dh\dm\ds]+)/i); + // Try to extract model from our error format "Rate limited on " or JSON format + const modelMatch = error.message.match(/Rate limited on ([^.]+)\./) || error.message.match(/"model":\s*"([^"]+)"/); + const model = modelMatch ? modelMatch[1] : 'the model'; + + if (resetMatch) { + errorMessage = `RESOURCE_EXHAUSTED: You have exhausted your capacity on ${model}. Quota will reset after ${resetMatch[1]}.`; + } else { + errorMessage = `RESOURCE_EXHAUSTED: You have exhausted your capacity on ${model}. Please wait for your quota to reset.`; + } + } else if (error.message.includes('invalid_request_error') || error.message.includes('INVALID_ARGUMENT')) { + errorType = 'invalid_request_error'; + statusCode = 400; + const msgMatch = error.message.match(/"message":"([^"]+)"/); + if (msgMatch) errorMessage = msgMatch[1]; + } else if (error.message.includes('All endpoints failed')) { + errorType = 'api_error'; + statusCode = 503; + errorMessage = 'Unable to connect to Claude API. Check that Antigravity is running.'; + } else if (error.message.includes('PERMISSION_DENIED')) { + errorType = 'permission_error'; + statusCode = 403; + errorMessage = errorMessage; + } + + return { errorType, statusCode, errorMessage }; +} + +// Request logging middleware +app.use((req, res, next) => { + const start = Date.now(); + + // Log response on finish + res.on('finish', () => { + const duration = Date.now() - start; + const status = res.statusCode; + const logMsg = `[${req.method}] ${req.originalUrl} ${status} (${duration}ms)`; + + // Skip standard logging for event logging batch unless in debug mode + if (req.originalUrl === '/api/event_logging/batch' || req.originalUrl.startsWith('/v1/messages/count_tokens') || req.originalUrl.startsWith('/.well-known/')) { + if (logger.isDebugEnabled) { + logger.debug(logMsg); + } + } else { + // Colorize status code + if (status >= 500) { + logger.error(logMsg); + } else if (status >= 400) { + logger.warn(logMsg); + } else { + logger.info(logMsg); + } + } + }); + + next(); +}); + +/** + * Silent handler for Claude Code CLI root POST requests + * Claude Code sends heartbeat/event requests to POST / which we don't need + */ +app.post('/', (req, res) => { + res.status(200).json({ status: 'ok' }); +}); + +/** + * Test endpoint - Clear thinking signature cache + * Used for testing cold cache scenarios in cross-model tests + */ +app.post('/test/clear-signature-cache', requireAdminAccess, (req, res) => { + clearThinkingSignatureCache(); + logger.debug('[Test] Cleared thinking signature cache'); + res.json({ success: true, message: 'Thinking signature cache cleared' }); +}); + +/** + * Health check endpoint - Detailed status + * Returns status of all accounts including rate limits and model quotas + */ +app.get('/health', async (req, res) => { + try { + await ensureInitialized(); + const start = Date.now(); + + // Get high-level status first + const status = accountManager.getStatus(); + const allAccounts = accountManager.getAllAccounts(); + + // Fetch quotas for each account in parallel to get detailed model info + const accountDetails = await Promise.allSettled( + allAccounts.map(async (account) => { + // Check model-specific rate limits + const activeModelLimits = Object.entries(account.modelRateLimits || {}) + .filter(([_, limit]) => limit.isRateLimited && limit.resetTime > Date.now()); + const isRateLimited = activeModelLimits.length > 0; + const soonestReset = activeModelLimits.length > 0 + ? Math.min(...activeModelLimits.map(([_, l]) => l.resetTime)) + : null; + + const baseInfo = { + email: account.email, + lastUsed: account.lastUsed ? new Date(account.lastUsed).toISOString() : null, + modelRateLimits: account.modelRateLimits || {}, + rateLimitCooldownRemaining: soonestReset ? Math.max(0, soonestReset - Date.now()) : 0 + }; + + // Skip invalid accounts for quota check + if (account.isInvalid) { + const isBanned = account.invalidReason?.toLowerCase().includes('banned') || + account.invalidReason?.toLowerCase().includes('terms of service'); + return { + ...baseInfo, + status: isBanned ? 'banned' : 'invalid', + error: account.invalidReason, + models: {} + }; + } + + try { + const token = await accountManager.getTokenForAccount(account); + const projectId = account.subscription?.projectId || null; + const quotas = await getModelQuotas(token, projectId); + + // Format quotas for readability + const formattedQuotas = {}; + for (const [modelId, info] of Object.entries(quotas)) { + formattedQuotas[modelId] = { + remaining: info.remainingFraction !== null ? `${Math.round(info.remainingFraction * 100)}%` : 'N/A', + remainingFraction: info.remainingFraction, + resetTime: info.resetTime || null + }; + } + + return { + ...baseInfo, + status: isRateLimited ? 'rate-limited' : 'ok', + models: formattedQuotas + }; + } catch (error) { + return { + ...baseInfo, + status: 'error', + error: error.message, + models: {} + }; + } + }) + ); + + // Process results + const detailedAccounts = accountDetails.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + const acc = allAccounts[index]; + return { + email: acc.email, + status: 'error', + error: result.reason?.message || 'Unknown error', + modelRateLimits: acc.modelRateLimits || {} + }; + } + }); + + const payload = { + status: 'ok', + timestamp: new Date().toISOString(), + latencyMs: Date.now() - start, + summary: status.summary, + counts: { + total: status.total, + available: status.available, + rateLimited: status.rateLimited, + invalid: status.invalid + }, + accounts: detailedAccounts + }; + + res.json(hasAdminAccess(req) ? payload : sanitizeHealthPayload(payload)); + + } catch (error) { + logger.error('[API] Health check failed:', error); + res.status(503).json({ + status: 'error', + error: error.message, + timestamp: new Date().toISOString() + }); + } +}); + +/** + * Account limits endpoint - fetch quota/limits for all accounts × all models + * Returns a table showing remaining quota and reset time for each combination + * Use ?format=table for ASCII table output, default is JSON + */ +app.get('/account-limits', requireAdminAccess, async (req, res) => { + try { + await ensureInitialized(); + const allAccounts = accountManager.getAllAccounts(); + const format = req.query.format || 'json'; + const includeHistory = req.query.includeHistory === 'true'; + + // Fetch quotas for each account in parallel + const results = await Promise.allSettled( + allAccounts.map(async (account) => { + // Skip invalid accounts + if (account.isInvalid) { + return { + email: account.email, + status: 'invalid', + error: account.invalidReason, + models: {} + }; + } + + try { + const token = await accountManager.getTokenForAccount(account); + + // Fetch subscription tier first to get project ID + const subscription = await getSubscriptionTier(token); + + // Then fetch quotas with project ID for accurate quota info + const quotas = await getModelQuotas(token, subscription.projectId); + + // Update account object with fresh data + account.subscription = { + tier: subscription.tier, + projectId: subscription.projectId, + detectedAt: Date.now() + }; + account.quota = { + models: quotas, + lastChecked: Date.now() + }; + + // Save updated account data to disk (async, don't wait) + accountManager.saveToDisk().catch(err => { + logger.error('[Server] Failed to save account data:', err); + }); + + return { + email: account.email, + status: 'ok', + subscription: account.subscription, + models: quotas + }; + } catch (error) { + // Detect ToS ban from quota/subscription fetch and mark account invalid + if (error.message?.startsWith('ACCOUNT_BANNED:')) { + accountManager.markInvalid(account.email, 'Account banned — Gemini disabled for Terms of Service violation'); + return { + email: account.email, + status: 'banned', + error: 'Account banned — Gemini disabled for Terms of Service violation', + subscription: account.subscription || { tier: 'unknown', projectId: null }, + models: {} + }; + } + return { + email: account.email, + status: 'error', + error: error.message, + subscription: account.subscription || { tier: 'unknown', projectId: null }, + models: {} + }; + } + }) + ); + + // Process results + const accountLimits = results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + email: allAccounts[index].email, + status: 'error', + error: result.reason?.message || 'Unknown error', + models: {} + }; + } + }); + + // Collect all unique model IDs + const allModelIds = new Set(); + for (const account of accountLimits) { + for (const modelId of Object.keys(account.models || {})) { + allModelIds.add(modelId); + } + } + + const sortedModels = Array.from(allModelIds).sort(); + + // Return ASCII table format + if (format === 'table') { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + + // Build table + const lines = []; + const timestamp = new Date().toLocaleString(); + lines.push(`Account Limits (${timestamp})`); + + // Get account status info + const status = accountManager.getStatus(); + lines.push(`Accounts: ${status.total} total, ${status.available} available, ${status.rateLimited} rate-limited, ${status.invalid} invalid`); + lines.push(''); + + // Table 1: Account status + const accColWidth = 25; + const statusColWidth = 15; + const lastUsedColWidth = 25; + const resetColWidth = 25; + + let accHeader = 'Account'.padEnd(accColWidth) + 'Status'.padEnd(statusColWidth) + 'Last Used'.padEnd(lastUsedColWidth) + 'Quota Reset'; + lines.push(accHeader); + lines.push('─'.repeat(accColWidth + statusColWidth + lastUsedColWidth + resetColWidth)); + + for (const acc of status.accounts) { + const shortEmail = acc.email.split('@')[0].slice(0, 22); + const lastUsed = acc.lastUsed ? new Date(acc.lastUsed).toLocaleString() : 'never'; + + // Get status and error from accountLimits + const accLimit = accountLimits.find(a => a.email === acc.email); + let accStatus; + if (acc.isInvalid) { + accStatus = 'invalid'; + } else if (accLimit?.status === 'error') { + accStatus = 'error'; + } else { + // Count exhausted models (0% or null remaining) + const models = accLimit?.models || {}; + const modelCount = Object.keys(models).length; + const exhaustedCount = Object.values(models).filter( + q => q.remainingFraction === 0 || q.remainingFraction === null + ).length; + + if (exhaustedCount === 0) { + accStatus = 'ok'; + } else { + accStatus = `(${exhaustedCount}/${modelCount}) limited`; + } + } + + // Get reset time from quota API + const claudeModel = sortedModels.find(m => m.includes('claude')); + const quota = claudeModel && accLimit?.models?.[claudeModel]; + const resetTime = quota?.resetTime + ? new Date(quota.resetTime).toLocaleString() + : '-'; + + let row = shortEmail.padEnd(accColWidth) + accStatus.padEnd(statusColWidth) + lastUsed.padEnd(lastUsedColWidth) + resetTime; + + // Add error on next line if present + if (accLimit?.error) { + lines.push(row); + lines.push(' └─ ' + accLimit.error); + } else { + lines.push(row); + } + } + lines.push(''); + + // Calculate column widths - need more space for reset time info + const modelColWidth = Math.max(28, ...sortedModels.map(m => m.length)) + 2; + const accountColWidth = 30; + + // Header row + let header = 'Model'.padEnd(modelColWidth); + for (const acc of accountLimits) { + const shortEmail = acc.email.split('@')[0].slice(0, 26); + header += shortEmail.padEnd(accountColWidth); + } + lines.push(header); + lines.push('─'.repeat(modelColWidth + accountLimits.length * accountColWidth)); + + // Data rows + for (const modelId of sortedModels) { + let row = modelId.padEnd(modelColWidth); + for (const acc of accountLimits) { + const quota = acc.models?.[modelId]; + let cell; + if (acc.status !== 'ok' && acc.status !== 'rate-limited') { + cell = `[${acc.status}]`; + } else if (!quota) { + cell = '-'; + } else if (quota.remainingFraction === 0 || quota.remainingFraction === null) { + // Show reset time for exhausted models + if (quota.resetTime) { + const resetMs = new Date(quota.resetTime).getTime() - Date.now(); + if (resetMs > 0) { + cell = `0% (wait ${formatDuration(resetMs)})`; + } else { + cell = '0% (resetting...)'; + } + } else { + cell = '0% (exhausted)'; + } + } else { + const pct = Math.round(quota.remainingFraction * 100); + cell = `${pct}%`; + } + row += cell.padEnd(accountColWidth); + } + lines.push(row); + } + + return res.send(lines.join('\n')); + } + + // Get account metadata from AccountManager + const accountStatus = accountManager.getStatus(); + const accountMetadataMap = new Map( + accountStatus.accounts.map(a => [a.email, a]) + ); + + // Build response data + const responseData = { + timestamp: new Date().toLocaleString(), + totalAccounts: allAccounts.length, + models: sortedModels, + modelConfig: config.modelMapping || {}, + globalQuotaThreshold: config.globalQuotaThreshold || 0, + accounts: accountLimits.map(acc => { + // Merge quota data with account metadata + const metadata = accountMetadataMap.get(acc.email) || {}; + return { + email: acc.email, + status: acc.status, + error: acc.error || null, + // Include metadata from AccountManager (WebUI needs these) + source: metadata.source || 'unknown', + enabled: metadata.enabled !== false, + projectId: metadata.projectId || null, + isInvalid: metadata.isInvalid || false, + invalidReason: metadata.invalidReason || null, + verifyUrl: metadata.verifyUrl || null, + lastUsed: metadata.lastUsed || null, + modelRateLimits: metadata.modelRateLimits || {}, + // Quota threshold settings + quotaThreshold: metadata.quotaThreshold, + modelQuotaThresholds: metadata.modelQuotaThresholds || {}, + // Subscription data (new) + subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null }, + // Quota limits + limits: Object.fromEntries( + sortedModels.map(modelId => { + const quota = acc.models?.[modelId]; + if (!quota) { + return [modelId, null]; + } + return [modelId, { + remaining: quota.remainingFraction !== null + ? `${Math.round(quota.remainingFraction * 100)}%` + : 'N/A', + remainingFraction: quota.remainingFraction, + resetTime: quota.resetTime || null + }]; + }) + ) + }; + }) + }; + + // Optionally include usage history (for dashboard performance optimization) + if (includeHistory) { + responseData.history = usageStats.getHistory(); + } + + res.json(responseData); + } catch (error) { + res.status(500).json({ + status: 'error', + error: error.message + }); + } +}); + +/** + * Force token refresh endpoint + */ +app.post('/refresh-token', requireAdminAccess, async (req, res) => { + try { + await ensureInitialized(); + // Clear all caches + accountManager.clearTokenCache(); + accountManager.clearProjectCache(); + // Force refresh default token + const token = await forceRefresh(); + res.json({ + status: 'ok', + message: 'Token caches cleared and refreshed', + tokenPrefix: token.substring(0, 10) + '...' + }); + } catch (error) { + res.status(500).json({ + status: 'error', + error: error.message + }); + } +}); + +/** + * List models endpoint (OpenAI-compatible format) + */ +app.get('/v1/models', async (req, res) => { + try { + await ensureInitialized(); + const { account } = accountManager.selectAccount(); + if (!account) { + return res.status(503).json({ + type: 'error', + error: { + type: 'api_error', + message: 'No accounts available' + } + }); + } + const token = await accountManager.getTokenForAccount(account); + const models = await listModels(token); + res.json(appendLegacyModelAliases(models)); + } catch (error) { + logger.error('[API] Error listing models:', error); + res.status(500).json({ + type: 'error', + error: { + type: 'api_error', + message: error.message + } + }); + } +}); + +registerOpenAIChatRoute(app, { + accountManager, + ensureInitialized, + fallbackEnabled: FALLBACK_ENABLED +}); + +/** + * Count tokens endpoint - Anthropic Messages API compatible + * Uses local tokenization with official tokenizers (@anthropic-ai/tokenizer for Claude, @lenml/tokenizer-gemini for Gemini) + */ +app.post('/v1/messages/count_tokens', (req, res) => { + res.status(501).json({ + type: 'error', + error: { + type: 'not_implemented', + message: 'Token counting is not implemented. Use /v1/messages with max_tokens or configure your client to skip token counting.' + } + }); +}); + +/** + * Main messages endpoint - Anthropic Messages API compatible + */ + + +/** + * Anthropic-compatible Messages API + * POST /v1/messages + */ +app.post('/v1/messages', async (req, res) => { + try { + // Ensure account manager is initialized + await ensureInitialized(); + + const { + model, + messages, + stream, + system, + max_tokens, + tools, + tool_choice, + thinking, + top_p, + top_k, + temperature + } = req.body; + + // Resolve configured and legacy model aliases before validation. + const originalModel = model || 'claude-3-5-sonnet-20241022'; + const modelId = resolveModelAlias(originalModel, config.modelMapping || {}); + if (modelId !== originalModel) { + logger.info(`[Server] Mapping model ${originalModel} -> ${modelId}`); + } + + // Validate model ID before processing + const { account: validationAccount } = accountManager.selectAccount(); + if (validationAccount) { + const token = await accountManager.getTokenForAccount(validationAccount); + const projectId = validationAccount.subscription?.projectId || null; + const valid = await isValidModel(modelId, token, projectId); + + if (!valid) { + throw new Error(`invalid_request_error: Invalid model: ${modelId}. Use /v1/models to see available models.`); + } + } + + // Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check. + // If we have some available accounts, we try them first. + if (accountManager.isAllRateLimited(modelId)) { + logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`); + accountManager.resetAllRateLimits(); + } + + // Validate required fields + if (!messages || !Array.isArray(messages)) { + return res.status(400).json({ + type: 'error', + error: { + type: 'invalid_request_error', + message: 'messages is required and must be an array' + } + }); + } + + // Filter out "count" requests (often automated background checks) + if (messages.length === 1 && messages[0].content === 'count') { + return res.json({}); + } + + // Build the request object + const request = { + model: modelId, + messages, + max_tokens: max_tokens || 4096, + stream, + system, + tools, + tool_choice, + thinking, + top_p, + top_k, + temperature + }; + + logger.info(`[API] Request for model: ${request.model}, stream: ${!!stream}`); + + // Debug: Log message structure to diagnose tool_use/tool_result ordering + if (logger.isDebugEnabled) { + logger.debug('[API] Message structure:'); + messages.forEach((msg, i) => { + const contentTypes = Array.isArray(msg.content) + ? msg.content.map(c => c.type || 'text').join(', ') + : (typeof msg.content === 'string' ? 'text' : 'unknown'); + logger.debug(` [${i}] ${msg.role}: ${contentTypes}`); + }); + } + + if (stream) { + // Handle streaming response + // Do NOT flush headers immediately. We need to wait for the first chunk + // to ensure we don't send a 200 OK if the upstream fails immediately (e.g. 429/503). + + try { + // Initialize the generator + const generator = sendMessageStream(request, accountManager, FALLBACK_ENABLED); + + // BUFFERING STRATEGY: + // Pull the first event *before* sending headers. + // If this throws, we can safely send a 4xx/5xx error JSON. + const firstResult = await generator.next(); + + // If we get here, the stream started successfully. + res.status(200); + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + // If the generator isn't done, send the first chunk + if (!firstResult.done) { + res.write(`event: ${firstResult.value.type}\ndata: ${JSON.stringify(firstResult.value)}\n\n`); + if (res.flush) res.flush(); + } + + // Continue with the rest of the stream + for await (const event of generator) { + res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + if (res.flush) res.flush(); + } + + res.end(); + + } catch (error) { + // If we haven't sent headers yet, we can send a proper error status + if (!res.headersSent) { + logger.error('[API] Initial stream error:', error); + const { errorType, statusCode, errorMessage } = parseError(error); + + return res.status(statusCode).json({ + type: 'error', + error: { + type: errorType, + message: errorMessage + } + }); + } + + // If headers were already sent (should only happen if error occurs mid-stream), + // we have to fallback to SSE error event + logger.error('[API] Mid-stream error:', error); + const { errorType, errorMessage } = parseError(error); + + res.write(`event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { type: errorType, message: errorMessage } + })}\n\n`); + res.end(); + } + + } else { + // Handle non-streaming response + const response = await sendMessage(request, accountManager, FALLBACK_ENABLED); + res.json(response); + } + + } catch (error) { + logger.error('[API] Error:', error); + + let { errorType, statusCode, errorMessage } = parseError(error); + + // For auth errors, try to refresh token + if (errorType === 'authentication_error') { + logger.warn('[API] Token might be expired, attempting refresh...'); + try { + accountManager.clearProjectCache(); + accountManager.clearTokenCache(); + await forceRefresh(); + errorMessage = 'Token was expired and has been refreshed. Please retry your request.'; + } catch (refreshError) { + errorMessage = 'Could not refresh token. Make sure Antigravity is running.'; + } + } + + logger.warn(`[API] Returning error response: ${statusCode} ${errorType} - ${errorMessage}`); + + // Check if headers have already been sent (for streaming that failed mid-way) + if (res.headersSent) { + logger.warn('[API] Headers already sent, writing error as SSE event'); + res.write(`event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { type: errorType, message: errorMessage } + })}\n\n`); + res.end(); + } else { + res.status(statusCode).json({ + type: 'error', + error: { + type: errorType, + message: errorMessage + } + }); + } + } +}); + +/** + * Catch-all for unsupported endpoints + */ +usageStats.setupRoutes(app); + +app.use('*', (req, res) => { + // Log 404s (use originalUrl since wildcard strips req.path) + if (logger.isDebugEnabled) { + logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`); + } + res.status(404).json({ + type: 'error', + error: { + type: 'not_found_error', + message: `Endpoint ${req.method} ${req.originalUrl} not found` + } + }); +}); + +export default app; \ No newline at end of file diff --git a/src/utils/claude-config.js b/src/utils/claude-config.js new file mode 100755 index 0000000000000000000000000000000000000000..e2da659e85ae608caf6da0bae9d4afef7da36d19 --- /dev/null +++ b/src/utils/claude-config.js @@ -0,0 +1,258 @@ +/** + * Claude CLI Configuration Utility + * + * Handles reading and writing to the global Claude CLI settings file. + * Location: ~/.claude/settings.json (Windows: %USERPROFILE%\.claude\settings.json) + * + * When running as a system service (e.g. systemd), os.homedir() resolves to the + * service user's home directory, not the actual user's. Set CLAUDE_CONFIG_PATH to + * the real user's ~/.claude directory to fix this. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { logger } from './logger.js'; +import { DEFAULT_PRESETS } from '../constants.js'; + +/** + * Get the path to the global Claude CLI settings file. + * + * Resolution order: + * 1. CLAUDE_CONFIG_PATH env var (path to .claude directory, e.g. /home/user/.claude) + * 2. Default: os.homedir()/.claude/settings.json + * + * @returns {string} Absolute path to settings.json + */ +export function getClaudeConfigPath() { + const configDir = process.env.CLAUDE_CONFIG_PATH; + if (configDir) { + return path.join(configDir, 'settings.json'); + } + return path.join(os.homedir(), '.claude', 'settings.json'); +} + +/** + * Read the global Claude CLI configuration + * @returns {Promise} The configuration object or empty object if file missing + */ +export async function readClaudeConfig() { + const configPath = getClaudeConfigPath(); + try { + const content = await fs.readFile(configPath, 'utf8'); + if (!content.trim()) return { env: {} }; + return JSON.parse(content); + } catch (error) { + if (error.code === 'ENOENT') { + logger.warn(`[ClaudeConfig] Config file not found at ${configPath}, returning empty default`); + return { env: {} }; + } + if (error instanceof SyntaxError) { + logger.error(`[ClaudeConfig] Invalid JSON in config at ${configPath}. Returning safe default.`); + return { env: {} }; + } + logger.error(`[ClaudeConfig] Failed to read config at ${configPath}:`, error.message); + throw error; + } +} + +/** + * Update the global Claude CLI configuration + * Performs a deep merge with existing configuration to avoid losing other settings. + * + * @param {Object} updates - The partial configuration to merge in + * @returns {Promise} The updated full configuration + */ +export async function updateClaudeConfig(updates) { + const configPath = getClaudeConfigPath(); + let currentConfig = {}; + + // 1. Read existing config + try { + currentConfig = await readClaudeConfig(); + } catch (error) { + // Ignore ENOENT, otherwise rethrow + if (error.code !== 'ENOENT') throw error; + } + + // 2. Deep merge updates + const newConfig = deepMerge(currentConfig, updates); + + // 3. Ensure .claude directory exists + const configDir = path.dirname(configPath); + try { + await fs.mkdir(configDir, { recursive: true }); + } catch (error) { + // Ignore if exists + } + + // 4. Write back to file + try { + await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2), 'utf8'); + logger.info(`[ClaudeConfig] Updated config at ${configPath}`); + return newConfig; + } catch (error) { + logger.error(`[ClaudeConfig] Failed to write config:`, error.message); + throw error; + } +} + +/** + * Replace the global Claude CLI configuration entirely + * Unlike updateClaudeConfig, this replaces the config instead of merging. + * + * @param {Object} config - The new configuration to write + * @returns {Promise} The written configuration + */ +export async function replaceClaudeConfig(config) { + const configPath = getClaudeConfigPath(); + + // 1. Ensure .claude directory exists + const configDir = path.dirname(configPath); + try { + await fs.mkdir(configDir, { recursive: true }); + } catch (error) { + // Ignore if exists + } + + // 2. Write config directly (no merge) + try { + await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); + logger.info(`[ClaudeConfig] Replaced config at ${configPath}`); + return config; + } catch (error) { + logger.error(`[ClaudeConfig] Failed to write config:`, error.message); + throw error; + } +} + +/** + * Simple deep merge for objects + */ +function deepMerge(target, source) { + const output = { ...target }; + + if (isObject(target) && isObject(source)) { + Object.keys(source).forEach(key => { + if (isObject(source[key])) { + if (!(key in target)) { + Object.assign(output, { [key]: source[key] }); + } else { + output[key] = deepMerge(target[key], source[key]); + } + } else { + Object.assign(output, { [key]: source[key] }); + } + }); + } + + return output; +} + +function isObject(item) { + return (item && typeof item === 'object' && !Array.isArray(item)); +} + +// ========================================== +// Claude CLI Presets +// ========================================== + +/** + * Get the path to the presets file + * @returns {string} Absolute path to claude-presets.json + */ +export function getPresetsPath() { + return path.join(os.homedir(), '.config', 'antigravity-proxy', 'claude-presets.json'); +} + +/** + * Read all Claude CLI presets + * Creates the file with default presets if it doesn't exist. + * @returns {Promise} Array of preset objects + */ +export async function readPresets() { + const presetsPath = getPresetsPath(); + try { + const content = await fs.readFile(presetsPath, 'utf8'); + if (!content.trim()) return DEFAULT_PRESETS; + return JSON.parse(content); + } catch (error) { + if (error.code === 'ENOENT') { + // Create with defaults + try { + await fs.mkdir(path.dirname(presetsPath), { recursive: true }); + await fs.writeFile(presetsPath, JSON.stringify(DEFAULT_PRESETS, null, 2), 'utf8'); + logger.info(`[ClaudePresets] Created presets file with defaults at ${presetsPath}`); + } catch (writeError) { + logger.warn(`[ClaudePresets] Could not create presets file: ${writeError.message}`); + } + return DEFAULT_PRESETS; + } + if (error instanceof SyntaxError) { + logger.error(`[ClaudePresets] Invalid JSON in presets at ${presetsPath}. Returning defaults.`); + return DEFAULT_PRESETS; + } + logger.error(`[ClaudePresets] Failed to read presets at ${presetsPath}:`, error.message); + throw error; + } +} + +/** + * Save a preset (add or update) + * @param {string} name - Preset name + * @param {Object} config - Environment variables to save + * @returns {Promise} Updated array of presets + */ +export async function savePreset(name, config) { + const presetsPath = getPresetsPath(); + let presets = await readPresets(); + + const existingIndex = presets.findIndex(p => p.name === name); + const newPreset = { name, config: { ...config } }; + + if (existingIndex >= 0) { + presets[existingIndex] = newPreset; + logger.info(`[ClaudePresets] Updated preset: ${name}`); + } else { + presets.push(newPreset); + logger.info(`[ClaudePresets] Created preset: ${name}`); + } + + try { + await fs.mkdir(path.dirname(presetsPath), { recursive: true }); + await fs.writeFile(presetsPath, JSON.stringify(presets, null, 2), 'utf8'); + } catch (error) { + logger.error(`[ClaudePresets] Failed to save preset:`, error.message); + throw error; + } + + return presets; +} + +/** + * Delete a preset by name + * @param {string} name - Preset name to delete + * @returns {Promise} Updated array of presets + */ +export async function deletePreset(name) { + const presetsPath = getPresetsPath(); + let presets = await readPresets(); + + const originalLength = presets.length; + presets = presets.filter(p => p.name !== name); + + if (presets.length === originalLength) { + logger.warn(`[ClaudePresets] Preset not found: ${name}`); + return presets; + } + + try { + await fs.writeFile(presetsPath, JSON.stringify(presets, null, 2), 'utf8'); + logger.info(`[ClaudePresets] Deleted preset: ${name}`); + } catch (error) { + logger.error(`[ClaudePresets] Failed to delete preset:`, error.message); + throw error; + } + + return presets; +} diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100755 index 0000000000000000000000000000000000000000..bf649433e20e1437abb3744f999ae2cecaed93f1 --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,100 @@ +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import path from 'path'; +import { config } from '../config.js'; + +/** + * Shared Utility Functions + * + * General-purpose helper functions used across multiple modules. + */ + +/** + * Get the package version from package.json + * @param {string} [defaultVersion='1.0.0'] - Default version if package.json cannot be read + * @returns {string} The package version + */ +export function getPackageVersion(defaultVersion = '1.0.0') { + try { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const packageJsonPath = path.join(__dirname, '../../package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + return packageJson.version || defaultVersion; + } catch { + return defaultVersion; + } +} + +/** + * Format duration in milliseconds to human-readable string + * @param {number} ms - Duration in milliseconds + * @returns {string} Human-readable duration (e.g., "1h23m45s") + */ +export function formatDuration(ms) { + const seconds = Math.floor(ms / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h${minutes}m${secs}s`; + } else if (minutes > 0) { + return `${minutes}m${secs}s`; + } + return `${secs}s`; +} + + +/** + * Sleep for specified milliseconds + * @param {number} ms - Duration to sleep in milliseconds + * @returns {Promise} Resolves after the specified duration + */ +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Check if an error is a network error (transient) + * @param {Error} error - The error to check + * @returns {boolean} True if it is a network error + */ +export function isNetworkError(error) { + const msg = error.message.toLowerCase(); + return ( + msg.includes('fetch failed') || + msg.includes('network error') || + msg.includes('econnreset') || + msg.includes('etimedout') || + msg.includes('socket hang up') || + msg.includes('timeout') + ); +} + +/** + * Throttled fetch that applies a configurable delay before each request + * Only applies delay when requestThrottlingEnabled is true + * @param {string|URL} url - The URL to fetch + * @param {RequestInit} [options] - Fetch options + * @returns {Promise} Fetch response + */ +export async function throttledFetch(url, options) { + if (config.requestThrottlingEnabled) { + const delayMs = config.requestDelayMs || 200; + if (delayMs > 0) { + await sleep(delayMs); + } + } + return fetch(url, options); +} + +/** + * Generate random jitter for backoff timing (Thundering Herd Prevention) + * Prevents all clients from retrying at the exact same moment after errors. + * @param {number} maxJitterMs - Maximum jitter range (result will be ±maxJitterMs/2) + * @returns {number} Random jitter value between -maxJitterMs/2 and +maxJitterMs/2 + */ +export function generateJitter(maxJitterMs) { + return Math.random() * maxJitterMs - (maxJitterMs / 2); +} diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100755 index 0000000000000000000000000000000000000000..f3f5314e561be2d4ca55b4dde34df3a8a24a80a6 --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,142 @@ +/** + * Logger Utility + * + * Provides structured logging with colors and debug support. + * Simple ANSI codes used to avoid dependencies. + */ + +import { EventEmitter } from 'events'; +import util from 'util'; + +const COLORS = { + RESET: '\x1b[0m', + BRIGHT: '\x1b[1m', + DIM: '\x1b[2m', + + RED: '\x1b[31m', + GREEN: '\x1b[32m', + YELLOW: '\x1b[33m', + BLUE: '\x1b[34m', + MAGENTA: '\x1b[35m', + CYAN: '\x1b[36m', + WHITE: '\x1b[37m', + GRAY: '\x1b[90m' +}; + +class Logger extends EventEmitter { + constructor() { + super(); + this.isDebugEnabled = false; + this.history = []; + this.maxHistory = 1000; + } + + /** + * Set debug mode + * @param {boolean} enabled + */ + setDebug(enabled) { + this.isDebugEnabled = !!enabled; + } + + /** + * Get current timestamp string + */ + getTimestamp() { + return new Date().toISOString(); + } + + /** + * Get log history + */ + getHistory() { + return this.history; + } + + /** + * Format and print a log message + * @param {string} level + * @param {string} color + * @param {string} message + * @param {...any} args + */ + print(level, color, message, ...args) { + // Format: [TIMESTAMP] [LEVEL] Message + const timestampStr = this.getTimestamp(); + const timestamp = `${COLORS.GRAY}[${timestampStr}]${COLORS.RESET}`; + const levelTag = `${color}[${level}]${COLORS.RESET}`; + + // Format the message with args similar to console.log + const formattedMessage = util.format(message, ...args); + + console.log(`${timestamp} ${levelTag} ${formattedMessage}`); + + // Store structured log + const logEntry = { + timestamp: timestampStr, + level, + message: formattedMessage + }; + + this.history.push(logEntry); + if (this.history.length > this.maxHistory) { + this.history.shift(); + } + + this.emit('log', logEntry); + } + + /** + * Standard info log + */ + info(message, ...args) { + this.print('INFO', COLORS.BLUE, message, ...args); + } + + /** + * Success log + */ + success(message, ...args) { + this.print('SUCCESS', COLORS.GREEN, message, ...args); + } + + /** + * Warning log + */ + warn(message, ...args) { + this.print('WARN', COLORS.YELLOW, message, ...args); + } + + /** + * Error log + */ + error(message, ...args) { + this.print('ERROR', COLORS.RED, message, ...args); + } + + /** + * Debug log - only prints if debug mode is enabled + */ + debug(message, ...args) { + if (this.isDebugEnabled) { + this.print('DEBUG', COLORS.MAGENTA, message, ...args); + } + } + + /** + * Direct log (for raw output usually) - proxied to console.log but can be enhanced + */ + log(message, ...args) { + console.log(message, ...args); + } + + /** + * Print a section header + */ + header(title) { + console.log(`\n${COLORS.BRIGHT}${COLORS.CYAN}=== ${title} ===${COLORS.RESET}\n`); + } +} + +// Export a singleton instance +export const logger = new Logger(); diff --git a/src/utils/native-module-helper.js b/src/utils/native-module-helper.js new file mode 100755 index 0000000000000000000000000000000000000000..c885684fe21b6986869588a226acd8a4fc4ea6b1 --- /dev/null +++ b/src/utils/native-module-helper.js @@ -0,0 +1,162 @@ +/** + * Native Module Helper + * Detects and auto-rebuilds native Node.js modules when they become + * incompatible after a Node.js version update. + */ + +import { execSync } from 'child_process'; +import { dirname, join } from 'path'; +import { existsSync } from 'fs'; +import { logger } from './logger.js'; + +/** + * Check if an error is a NODE_MODULE_VERSION mismatch error + * @param {Error} error - The error to check + * @returns {boolean} True if it's a version mismatch error + */ +export function isModuleVersionError(error) { + const message = error?.message || ''; + return message.includes('NODE_MODULE_VERSION') && + message.includes('was compiled against a different Node.js version'); +} + +/** + * Extract the module path from a NODE_MODULE_VERSION error message + * @param {Error} error - The error containing the module path + * @returns {string|null} The path to the .node file, or null if not found + */ +export function extractModulePath(error) { + const message = error?.message || ''; + // Match pattern like: "The module '/path/to/module.node'" + const match = message.match(/The module '([^']+\.node)'/); + return match ? match[1] : null; +} + +/** + * Find the package root directory from a .node file path + * @param {string} nodeFilePath - Path to the .node file + * @returns {string|null} Path to the package root, or null if not found + */ +export function findPackageRoot(nodeFilePath) { + // Walk up from the .node file to find package.json + let dir = dirname(nodeFilePath); + while (dir) { + const packageJsonPath = join(dir, 'package.json'); + if (existsSync(packageJsonPath)) { + return dir; + } + const parentDir = dirname(dir); + // Stop when we've reached the filesystem root (dirname returns same path) + if (parentDir === dir) { + break; + } + dir = parentDir; + } + return null; +} + +/** + * Attempt to rebuild a native module + * @param {string} packagePath - Path to the package root directory + * @returns {boolean} True if rebuild succeeded, false otherwise + */ +export function rebuildModule(packagePath) { + try { + logger.info(`[NativeModule] Rebuilding native module at: ${packagePath}`); + + // Run npm rebuild in the package directory + const output = execSync('npm rebuild', { + cwd: packagePath, + stdio: 'pipe', // Capture output instead of printing + timeout: 120000 // 2 minute timeout + }); + + // Log rebuild output for debugging + const outputStr = output?.toString().trim(); + if (outputStr) { + logger.debug(`[NativeModule] Rebuild output:\n${outputStr}`); + } + + logger.success('[NativeModule] Rebuild completed successfully'); + return true; + } catch (error) { + // Include stdout/stderr from the failed command for troubleshooting + const stdout = error.stdout?.toString().trim(); + const stderr = error.stderr?.toString().trim(); + let errorDetails = `[NativeModule] Rebuild failed: ${error.message}`; + if (stdout) { + errorDetails += `\n[NativeModule] stdout: ${stdout}`; + } + if (stderr) { + errorDetails += `\n[NativeModule] stderr: ${stderr}`; + } + logger.error(errorDetails); + return false; + } +} + +/** + * Attempt to auto-rebuild a native module from an error + * @param {Error} error - The NODE_MODULE_VERSION error + * @returns {boolean} True if rebuild succeeded, false otherwise + */ +export function attemptAutoRebuild(error) { + const nodePath = extractModulePath(error); + if (!nodePath) { + logger.error('[NativeModule] Could not extract module path from error'); + return false; + } + + const packagePath = findPackageRoot(nodePath); + if (!packagePath) { + logger.error('[NativeModule] Could not find package root'); + return false; + } + + logger.warn('[NativeModule] Native module version mismatch detected'); + logger.info('[NativeModule] Attempting automatic rebuild...'); + + return rebuildModule(packagePath); +} + +/** + * Recursively clear a module and its dependencies from the require cache + * This is needed after rebuilding a native module to force re-import + * @param {string} modulePath - Resolved path to the module + * @param {object} cache - The require.cache object + * @param {Set} [visited] - Set of already-visited paths to prevent cycles + */ +export function clearRequireCache(modulePath, cache, visited = new Set()) { + if (visited.has(modulePath)) return; + visited.add(modulePath); + + const mod = cache[modulePath]; + if (!mod) return; + + // Recursively clear children first + if (mod.children) { + for (const child of mod.children) { + clearRequireCache(child.id, cache, visited); + } + } + + // Remove from parent's children array + if (mod.parent && mod.parent.children) { + const idx = mod.parent.children.indexOf(mod); + if (idx !== -1) { + mod.parent.children.splice(idx, 1); + } + } + + // Delete from cache + delete cache[modulePath]; +} + +export default { + isModuleVersionError, + extractModulePath, + findPackageRoot, + rebuildModule, + attemptAutoRebuild, + clearRequireCache +}; diff --git a/src/utils/proxy.js b/src/utils/proxy.js new file mode 100755 index 0000000000000000000000000000000000000000..578cb8ccb460322bdb49e9efcd9b45e220367f29 --- /dev/null +++ b/src/utils/proxy.js @@ -0,0 +1,38 @@ +/** + * HTTP Proxy Support + * + * Configures global fetch to use HTTP proxy from environment variables. + * Supports: http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY + * + * This module should be imported at the very beginning of the application + * entry point (src/index.js) before any fetch calls are made. + */ + +import { ProxyAgent, setGlobalDispatcher } from 'undici'; +import { logger } from './logger.js'; + +/** + * Initialize proxy support from environment variables + * Call this once at application startup + */ +export function initProxy() { + const proxyUrl = process.env.http_proxy || + process.env.HTTP_PROXY || + process.env.https_proxy || + process.env.HTTPS_PROXY; + + if (!proxyUrl) { + return; + } + + try { + const proxyAgent = new ProxyAgent(proxyUrl); + setGlobalDispatcher(proxyAgent); + logger.info(`[Proxy] Using proxy: ${proxyUrl}`); + } catch (error) { + logger.error(`[Proxy] Failed to configure proxy: ${error.message}`); + } +} + +// Auto-initialize on import +initProxy(); diff --git a/src/utils/public-sanitize.js b/src/utils/public-sanitize.js new file mode 100755 index 0000000000000000000000000000000000000000..d20b1b7a01977840197f509e9b0276f819ac7c94 --- /dev/null +++ b/src/utils/public-sanitize.js @@ -0,0 +1,88 @@ +/** + * Strip PII from monitoring endpoints for unauthenticated visitors. + */ + +export function maskEmail(email) { + const [local, domain] = String(email || '').split('@'); + if (!local || !domain) { + return 'account'; + } + if (local.length <= 4) { + return `${local[0]}***@${domain}`; + } + return `${local.slice(0, 2)}******${local.slice(-2)}@${domain}`; +} + +function sanitizeSubscription(subscription) { + if (!subscription || typeof subscription !== 'object') { + return undefined; + } + return { + tier: subscription.tier || 'unknown' + }; +} + +function sanitizeModelQuotas(models) { + if (!models || typeof models !== 'object') { + return {}; + } + + const sanitized = {}; + for (const [modelId, info] of Object.entries(models)) { + sanitized[modelId] = { + remaining: info?.remaining ?? null, + remainingFraction: info?.remainingFraction ?? null, + resetTime: info?.resetTime ?? null + }; + } + return sanitized; +} + +export function sanitizeHealthAccount(account, index) { + return { + id: `account-${index + 1}`, + email: maskEmail(account?.email), + status: account?.status || 'unknown', + modelRateLimits: {}, + rateLimitCooldownRemaining: account?.rateLimitCooldownRemaining ?? 0, + models: sanitizeModelQuotas(account?.models), + ...(account?.error ? { error: 'Unavailable' } : {}) + }; +} + +export function sanitizeHealthPayload(payload) { + return { + status: payload?.status, + timestamp: payload?.timestamp, + latencyMs: payload?.latencyMs, + summary: payload?.summary, + counts: payload?.counts, + accounts: (payload?.accounts || []).map(sanitizeHealthAccount) + }; +} + +export function sanitizeAccountLimitsAccount(account, index) { + return { + id: `account-${index + 1}`, + email: maskEmail(account?.email), + status: account?.status || 'unknown', + error: account?.error ? 'Unavailable' : null, + source: account?.source || 'unknown', + enabled: account?.enabled !== false, + isInvalid: Boolean(account?.isInvalid), + subscription: sanitizeSubscription(account?.subscription), + limits: sanitizeModelQuotas(account?.limits || account?.models), + models: sanitizeModelQuotas(account?.models) + }; +} + +export function sanitizeAccountLimitsPayload(payload) { + return { + timestamp: payload?.timestamp, + totalAccounts: payload?.totalAccounts, + models: payload?.models || [], + modelConfig: {}, + globalQuotaThreshold: payload?.globalQuotaThreshold ?? 0, + accounts: (payload?.accounts || []).map(sanitizeAccountLimitsAccount) + }; +} diff --git a/src/utils/server-presets.js b/src/utils/server-presets.js new file mode 100755 index 0000000000000000000000000000000000000000..6adb21c73b0d53be5aba11c1266d0f6de1be440a --- /dev/null +++ b/src/utils/server-presets.js @@ -0,0 +1,226 @@ +/** + * Server Configuration Presets Utility + * + * Handles reading and writing server config presets. + * Location: ~/.config/antigravity-proxy/server-presets.json + */ + +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { logger } from './logger.js'; +import { DEFAULT_SERVER_PRESETS } from '../constants.js'; + +/** + * Get the path to the server presets file + * @returns {string} Absolute path to server-presets.json + */ +export function getServerPresetsPath() { + return path.join(os.homedir(), '.config', 'antigravity-proxy', 'server-presets.json'); +} + +/** + * Read all server config presets. + * Creates the file with default presets if it doesn't exist. + * @returns {Promise} Array of preset objects + */ +export async function readServerPresets() { + const presetsPath = getServerPresetsPath(); + try { + const content = await fs.readFile(presetsPath, 'utf8'); + if (!content.trim()) return DEFAULT_SERVER_PRESETS; + const userPresets = JSON.parse(content); + // Merge: always include built-in presets (latest version), then user custom presets + const builtInNames = new Set(DEFAULT_SERVER_PRESETS.map(p => p.name)); + const customPresets = userPresets.filter(p => !builtInNames.has(p.name) && !p.builtIn); + return [...DEFAULT_SERVER_PRESETS, ...customPresets]; + } catch (error) { + if (error.code === 'ENOENT') { + try { + await fs.mkdir(path.dirname(presetsPath), { recursive: true }); + await fs.writeFile(presetsPath, JSON.stringify(DEFAULT_SERVER_PRESETS, null, 2), 'utf8'); + logger.info(`[ServerPresets] Created presets file with defaults at ${presetsPath}`); + } catch (writeError) { + logger.warn(`[ServerPresets] Could not create presets file: ${writeError.message}`); + } + return DEFAULT_SERVER_PRESETS; + } + if (error instanceof SyntaxError) { + logger.error(`[ServerPresets] Invalid JSON in presets at ${presetsPath}. Returning defaults.`); + return DEFAULT_SERVER_PRESETS; + } + logger.error(`[ServerPresets] Failed to read presets at ${presetsPath}:`, error.message); + throw error; + } +} + +/** + * Save a custom server preset (add or update). + * Rejects overwriting built-in presets. + * @param {string} name - Preset name + * @param {Object} config - Server configuration values + * @param {string} [description] - Optional user description + * @returns {Promise} Updated array of all presets + */ +export async function saveServerPreset(name, config, description) { + // Reject overwriting built-in presets + const builtInNames = new Set(DEFAULT_SERVER_PRESETS.map(p => p.name)); + if (builtInNames.has(name)) { + throw new Error(`Cannot overwrite built-in preset "${name}"`); + } + + const presetsPath = getServerPresetsPath(); + let allPresets = await readServerPresets(); + + // Find or create user custom preset + const existingIndex = allPresets.findIndex(p => p.name === name && !p.builtIn); + const newPreset = { name, config: { ...config } }; + if (description && typeof description === 'string' && description.trim()) { + newPreset.description = description.trim(); + } + + if (existingIndex >= 0) { + allPresets[existingIndex] = newPreset; + logger.info(`[ServerPresets] Updated preset: ${name}`); + } else { + allPresets.push(newPreset); + logger.info(`[ServerPresets] Created preset: ${name}`); + } + + try { + await fs.mkdir(path.dirname(presetsPath), { recursive: true }); + await fs.writeFile(presetsPath, JSON.stringify(allPresets, null, 2), 'utf8'); + } catch (error) { + logger.error(`[ServerPresets] Failed to save preset:`, error.message); + throw error; + } + + return allPresets; +} + +/** + * Update metadata (name, description) of a custom server preset. + * Rejects editing built-in presets. + * @param {string} currentName - Current preset name + * @param {Object} updates - Fields to update ({ name, description }) + * @returns {Promise} Updated array of all presets + */ +export async function updateServerPreset(currentName, updates) { + const builtInNames = new Set(DEFAULT_SERVER_PRESETS.map(p => p.name)); + if (builtInNames.has(currentName)) { + throw new Error(`Cannot edit built-in preset "${currentName}"`); + } + + const presetsPath = getServerPresetsPath(); + let allPresets = await readServerPresets(); + + const index = allPresets.findIndex(p => p.name === currentName && !p.builtIn); + if (index < 0) { + throw new Error(`Preset "${currentName}" not found`); + } + + // Check new name doesn't collide with a built-in + if (updates.name && builtInNames.has(updates.name)) { + throw new Error(`Cannot use built-in preset name "${updates.name}"`); + } + + // Check new name doesn't collide with another custom preset + if (updates.name && updates.name !== currentName) { + const conflict = allPresets.findIndex(p => p.name === updates.name && !p.builtIn); + if (conflict >= 0) { + throw new Error(`A preset named "${updates.name}" already exists`); + } + } + + if (updates.name) { + allPresets[index].name = updates.name.trim(); + } + if (updates.description !== undefined) { + if (updates.description && updates.description.trim()) { + allPresets[index].description = updates.description.trim(); + } else { + delete allPresets[index].description; + } + } + + // Merge config updates if provided + if (updates.config && typeof updates.config === 'object') { + const allowedKeys = [ + 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs', + 'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold', + 'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs', + 'maxCapacityRetries', 'switchAccountDelayMs', 'capacityBackoffTiersMs', + 'accountSelection' + ]; + const existing = allPresets[index].config || {}; + for (const key of allowedKeys) { + if (updates.config[key] !== undefined) { + if (key === 'accountSelection') { + const updateAS = updates.config.accountSelection; + if (!updateAS || typeof updateAS !== 'object') continue; + // Deep merge accountSelection sub-objects to preserve fields not in partial update + const existingAS = existing.accountSelection || {}; + existing.accountSelection = { ...existingAS }; + if (updateAS.strategy !== undefined) existing.accountSelection.strategy = updateAS.strategy; + for (const subKey of ['healthScore', 'tokenBucket', 'quota', 'weights']) { + if (updateAS[subKey] && typeof updateAS[subKey] === 'object') { + existing.accountSelection[subKey] = { ...existingAS[subKey], ...updateAS[subKey] }; + } + } + } else { + existing[key] = updates.config[key]; + } + } + } + allPresets[index].config = existing; + } + + const hasConfigChange = updates.config && Object.keys(updates.config).length > 0; + const hasNameChange = updates.name && updates.name !== currentName; + + try { + await fs.mkdir(path.dirname(presetsPath), { recursive: true }); + await fs.writeFile(presetsPath, JSON.stringify(allPresets, null, 2), 'utf8'); + logger.info(`[ServerPresets] Updated preset${hasConfigChange ? ' config' : ' metadata'}: ${currentName}${hasNameChange ? ` → ${updates.name}` : ''}`); + } catch (error) { + logger.error(`[ServerPresets] Failed to update preset:`, error.message); + throw error; + } + + return allPresets; +} + +/** + * Delete a custom server preset by name. + * Rejects deletion of built-in presets. + * @param {string} name - Preset name to delete + * @returns {Promise} Updated array of all presets + */ +export async function deleteServerPreset(name) { + // Reject deleting built-in presets + const builtInNames = new Set(DEFAULT_SERVER_PRESETS.map(p => p.name)); + if (builtInNames.has(name)) { + throw new Error(`Cannot delete built-in preset "${name}"`); + } + + const presetsPath = getServerPresetsPath(); + let allPresets = await readServerPresets(); + + const originalLength = allPresets.length; + allPresets = allPresets.filter(p => p.name !== name); + + if (allPresets.length === originalLength) { + logger.warn(`[ServerPresets] Preset not found: ${name}`); + return allPresets; + } + + try { + await fs.writeFile(presetsPath, JSON.stringify(allPresets, null, 2), 'utf8'); + logger.info(`[ServerPresets] Deleted preset: ${name}`); + } catch (error) { + logger.error(`[ServerPresets] Failed to delete preset:`, error.message); + throw error; + } + + return allPresets; +} diff --git a/src/utils/version-detector.js b/src/utils/version-detector.js new file mode 100755 index 0000000000000000000000000000000000000000..bcedcdb0fa3f2947659e00318a30e7002862abdc --- /dev/null +++ b/src/utils/version-detector.js @@ -0,0 +1,244 @@ +import { execSync } from 'child_process'; +import { platform, homedir } from 'os'; +import { join } from 'path'; +import { existsSync, readFileSync } from 'fs'; + +/** + * Intelligent Version Detection for Antigravity + * + * Detects versions from the local Antigravity installation's product.json. + * Two version values are tracked: + * - "version" field → X-Client-Version header (API version gate) + * - "ideVersion" field → User-Agent version string + * + * Detection priority (for each): + * 1. Environment variable override + * 2. product.json from local Antigravity app + * 3. OS-specific detection (macOS plist / Windows exe) + * 4. Hardcoded fallback + */ + +// Fallback for User-Agent version (ideVersion in product.json) +const FALLBACK_USER_AGENT_VERSION = process.env.FALLBACK_ANTIGRAVITY_VERSION || '2.0.3'; + +// Fallback for X-Client-Version (top-level "version" in product.json) +// Can be overridden via ANTIGRAVITY_CLIENT_VERSION_FALLBACK env var +const FALLBACK_CLIENT_VERSION = process.env.ANTIGRAVITY_CLIENT_VERSION_FALLBACK || '1.110.0'; + +let cachedUserAgent = null; +let cachedClientVersion = null; +let cachedProductJson = undefined; // undefined = not yet attempted +let loggedVersionInfo = false; + +/** + * Compares two semver-ish version strings (X.Y.Z). + * @returns {boolean} True if v1 > v2 + */ +function isVersionHigher(v1, v2) { + const parts1 = v1.split('.').map(Number); + const parts2 = v2.split('.').map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0; + const p2 = parts2[i] || 0; + if (p1 > p2) return true; + if (p1 < p2) return false; + } + return false; +} + +/** + * Returns platform-specific search paths for product.json. + */ +function getProductJsonPaths() { + const os = platform(); + const paths = []; + + if (os === 'darwin') { + paths.push('/Applications/Antigravity.app/Contents/Resources/app/product.json'); + paths.push(join(homedir(), 'Applications', 'Antigravity.app', 'Contents', 'Resources', 'app', 'product.json')); + } else if (os === 'win32') { + const localAppData = process.env.LOCALAPPDATA; + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + if (localAppData) { + paths.push(join(localAppData, 'Programs', 'Antigravity', 'resources', 'app', 'product.json')); + } + paths.push(join(programFiles, 'Antigravity', 'resources', 'app', 'product.json')); + } else { + paths.push('/usr/share/antigravity/resources/app/product.json'); + paths.push('/opt/antigravity/resources/app/product.json'); + paths.push('/opt/Antigravity/resources/app/product.json'); + paths.push(join(homedir(), '.local', 'share', 'antigravity', 'resources', 'app', 'product.json')); + paths.push('/snap/antigravity/current/resources/app/product.json'); + } + + return paths; +} + +/** + * Find and parse product.json from the local Antigravity installation. + * Caches the result after first attempt. + * @returns {Object|null} Parsed product.json or null + */ +function getProductJson() { + if (cachedProductJson !== undefined) return cachedProductJson; + + for (const p of getProductJsonPaths()) { + try { + if (existsSync(p)) { + const content = JSON.parse(readFileSync(p, 'utf8')); + if (content && (content.version || content.ideVersion)) { + cachedProductJson = content; + return content; + } + } + } catch (e) { + // Continue to next path + } + } + + cachedProductJson = null; + return null; +} + +/** + * Log version detection results once at startup (lazy import to avoid circular deps). + */ +function logVersionInfo(version, source) { + if (loggedVersionInfo) return; + loggedVersionInfo = true; + + import('./logger.js').then(({ logger }) => { + if (source === 'fallback') { + logger.warn(`X-Client-Version: using hardcoded fallback ${version} — product.json not found. Set ANTIGRAVITY_CLIENT_VERSION (exact version) or ANTIGRAVITY_CLIENT_VERSION_FALLBACK (fallback value) env var to override.`); + } else { + logger.debug(`X-Client-Version: ${version} (source: ${source})`); + } + }).catch(() => {}); +} + +/** + * Get the X-Client-Version value for API requests. + * Priority: ANTIGRAVITY_CLIENT_VERSION env var > product.json "version" > hardcoded fallback + * @returns {string} Version string (e.g. "1.110.0") + */ +export function getClientVersion() { + if (cachedClientVersion) return cachedClientVersion; + + if (process.env.ANTIGRAVITY_CLIENT_VERSION) { + cachedClientVersion = process.env.ANTIGRAVITY_CLIENT_VERSION; + logVersionInfo(cachedClientVersion, 'env'); + return cachedClientVersion; + } + + const product = getProductJson(); + if (product?.version) { + cachedClientVersion = product.version; + logVersionInfo(cachedClientVersion, 'product.json'); + return cachedClientVersion; + } + + cachedClientVersion = FALLBACK_CLIENT_VERSION; + logVersionInfo(cachedClientVersion, 'fallback'); + return cachedClientVersion; +} + +/** + * Get the User-Agent version string. + * Priority: FALLBACK_ANTIGRAVITY_VERSION env var > product.json "ideVersion" > OS detection > fallback + * @returns {{ version: string, source: string }} + */ +function getUserAgentVersionConfig() { + if (process.env.FALLBACK_ANTIGRAVITY_VERSION) { + return { version: process.env.FALLBACK_ANTIGRAVITY_VERSION, source: 'env' }; + } + + const product = getProductJson(); + if (product?.ideVersion && isVersionHigher(product.ideVersion, FALLBACK_USER_AGENT_VERSION)) { + return { version: product.ideVersion, source: 'product.json' }; + } + + // OS-specific detection (legacy — reads app binary metadata directly) + const os = platform(); + let detectedVersion = null; + try { + if (os === 'darwin') { + detectedVersion = getVersionMacos(); + } else if (os === 'win32') { + detectedVersion = getVersionWindows(); + } + } catch (error) { + // Silently fail and use fallback + } + + if (detectedVersion && isVersionHigher(detectedVersion, FALLBACK_USER_AGENT_VERSION)) { + return { version: detectedVersion, source: 'local' }; + } + + return { version: FALLBACK_USER_AGENT_VERSION, source: 'fallback' }; +} + +/** + * Generate a simplified User-Agent string used by the Antigravity binary. + * Format: "antigravity/version os/arch" + * @returns {string} The User-Agent string + */ +export function generateSmartUserAgent() { + if (cachedUserAgent) return cachedUserAgent; + + const { version } = getUserAgentVersionConfig(); + const os = platform(); + const architecture = process.arch; + + const osName = os === 'darwin' ? 'darwin' : (os === 'win32' ? 'win32' : 'linux'); + + cachedUserAgent = `antigravity/${version} ${osName}/${architecture}`; + return cachedUserAgent; +} + +/** + * MacOS-specific version detection using plutil + */ +function getVersionMacos() { + const appPath = '/Applications/Antigravity.app'; + const plistPath = join(appPath, 'Contents/Info.plist'); + + if (!existsSync(plistPath)) return null; + + try { + const version = execSync(`plutil -extract CFBundleShortVersionString raw "${plistPath}"`, { encoding: 'utf8' }).trim(); + if (/^\d+\.\d+\.\d+/.test(version)) { + return version; + } + } catch (e) { + // plutil failed or file not found + } + return null; +} + +/** + * Windows-specific version detection using PowerShell + */ +function getVersionWindows() { + try { + const localAppData = process.env.LOCALAPPDATA; + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + + const possiblePaths = [ + join(localAppData, 'Programs', 'Antigravity', 'Antigravity.exe'), + join(programFiles, 'Antigravity', 'Antigravity.exe') + ]; + + for (const exePath of possiblePaths) { + if (existsSync(exePath)) { + const cmd = `powershell -Command "(Get-Item '${exePath}').VersionInfo.FileVersion"`; + const version = execSync(cmd, { encoding: 'utf8' }).trim(); + const match = version.match(/^(\d+\.\d+\.\d+)/); + if (match) return match[1]; + } + } + } catch (e) { + // PowerShell or path issues + } + return null; +} diff --git a/src/webui/index.js b/src/webui/index.js new file mode 100755 index 0000000000000000000000000000000000000000..2e6acfae7dc016a7731a5b90ec2fd5590a06b702 --- /dev/null +++ b/src/webui/index.js @@ -0,0 +1,1286 @@ +/** + * WebUI Module - Optional web interface for account management + * + * This module provides a web-based UI for: + * - Dashboard with real-time model quota visualization + * - Account management (add via OAuth, enable/disable, refresh, remove) + * - Live server log streaming with filtering + * - Claude CLI configuration editor + * + * Usage in server.js: + * import { mountWebUI } from './webui/index.js'; + * mountWebUI(app, __dirname, accountManager); + */ + +import path from 'path'; +import express from 'express'; +import { getPublicConfig, saveConfig, config } from '../config.js'; +import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS, DEFAULT_PRESETS, DEFAULT_SERVER_PRESETS } from '../constants.js'; +import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js'; +import { readServerPresets, saveServerPreset, updateServerPreset, deleteServerPreset } from '../utils/server-presets.js'; +import { logger } from '../utils/logger.js'; +import { hasAdminAccess } from '../middleware/api-key-auth.js'; +import { loadAccounts, saveAccounts } from '../account-manager/storage.js'; +import { getPackageVersion } from '../utils/helpers.js'; + +// Get package version +const packageVersion = getPackageVersion(); + +// OAuth state storage (state -> { server, verifier, state, timestamp }) +// Maps state ID to active OAuth flow data +const pendingOAuthFlows = new Map(); + +/** + * WebUI Helper Functions - Direct account manipulation + * These functions work around AccountManager's limited API by directly + * manipulating the accounts.json config file (non-invasive approach for PR) + */ + +/** + * Set account enabled/disabled state + */ +async function setAccountEnabled(email, enabled) { + const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); + const account = accounts.find(a => a.email === email); + if (!account) { + throw new Error(`Account ${email} not found`); + } + account.enabled = enabled; + await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); + logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`); +} + +/** + * Remove account from config + */ +async function removeAccount(email) { + const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); + const index = accounts.findIndex(a => a.email === email); + if (index === -1) { + throw new Error(`Account ${email} not found`); + } + accounts.splice(index, 1); + // Adjust activeIndex if needed + const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex; + await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex); + logger.info(`[WebUI] Account ${email} removed`); +} + +/** + * Add new account to config + * @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only) + */ +async function addAccount(accountData) { + const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); + + // Check if account already exists + const existingIndex = accounts.findIndex(a => a.email === accountData.email); + if (existingIndex !== -1) { + // Update existing account + accounts[existingIndex] = { + ...accounts[existingIndex], + ...accountData, + enabled: true, + isInvalid: false, + invalidReason: null, + addedAt: accounts[existingIndex].addedAt || new Date().toISOString() + }; + logger.info(`[WebUI] Account ${accountData.email} updated`); + } else { + // Check MAX_ACCOUNTS limit before adding new account + if (accounts.length >= MAX_ACCOUNTS) { + throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached. Update maxAccounts in config to increase the limit.`); + } + // Add new account + accounts.push({ + ...accountData, + enabled: true, + isInvalid: false, + invalidReason: null, + modelRateLimits: {}, + lastUsed: null, + addedAt: new Date().toISOString() + }); + logger.info(`[WebUI] Account ${accountData.email} added`); + } + + await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); +} + +/** + * Auth Middleware - Optional password protection for WebUI + * Password can be set via WEBUI_PASSWORD env var or config.json + */ +function createAuthMiddleware() { + return (req, res, next) => { + const password = config.webuiPassword; + if (!password) return next(); + + // Determine if this path should be protected + const isApiRoute = req.path.startsWith('/api/'); + const isAuthUrl = req.path === '/api/auth/url'; + const isConfigGet = req.path === '/api/config' && req.method === 'GET'; + const isProtected = (isApiRoute && !isAuthUrl && !isConfigGet) || req.path === '/account-limits' || req.path === '/health'; + + if (isProtected) { + if (hasAdminAccess(req)) { + return next(); + } + return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' }); + } + next(); + }; +} + +/** + * Validate server config fields from user input. + * Shared by POST /api/config and PATCH /api/server/presets/:name. + * @param {Object} input - Raw config fields to validate + * @returns {Object} Validated updates object (only valid fields included) + */ +function validateConfigFields(input) { + const updates = {}; + const { maxRetries, retryBaseMs, retryMaxMs, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, globalQuotaThreshold, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries, switchAccountDelayMs, capacityBackoffTiersMs } = input; + + if (typeof maxRetries === 'number' && maxRetries >= 1 && maxRetries <= 20) { + updates.maxRetries = maxRetries; + } + if (typeof retryBaseMs === 'number' && retryBaseMs >= 100 && retryBaseMs <= 10000) { + updates.retryBaseMs = retryBaseMs; + } + if (typeof retryMaxMs === 'number' && retryMaxMs >= 1000 && retryMaxMs <= 120000) { + updates.retryMaxMs = retryMaxMs; + } + if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) { + updates.defaultCooldownMs = defaultCooldownMs; + } + if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) { + updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs; + } + if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) { + updates.maxAccounts = maxAccounts; + } + if (typeof globalQuotaThreshold === 'number' && globalQuotaThreshold >= 0 && globalQuotaThreshold < 1) { + updates.globalQuotaThreshold = globalQuotaThreshold; + } + if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) { + updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs; + } + if (typeof maxConsecutiveFailures === 'number' && maxConsecutiveFailures >= 1 && maxConsecutiveFailures <= 10) { + updates.maxConsecutiveFailures = maxConsecutiveFailures; + } + if (typeof extendedCooldownMs === 'number' && extendedCooldownMs >= 10000 && extendedCooldownMs <= 300000) { + updates.extendedCooldownMs = extendedCooldownMs; + } + if (typeof maxCapacityRetries === 'number' && maxCapacityRetries >= 1 && maxCapacityRetries <= 10) { + updates.maxCapacityRetries = maxCapacityRetries; + } + if (typeof switchAccountDelayMs === 'number' && switchAccountDelayMs >= 1000 && switchAccountDelayMs <= 60000) { + updates.switchAccountDelayMs = switchAccountDelayMs; + } + if (Array.isArray(capacityBackoffTiersMs) && capacityBackoffTiersMs.length >= 1 && capacityBackoffTiersMs.length <= 10) { + const allValid = capacityBackoffTiersMs.every(v => typeof v === 'number' && v >= 1000 && v <= 300000); + if (allValid) { + updates.capacityBackoffTiersMs = [...capacityBackoffTiersMs]; + } + } + // Account selection strategy and tuning validation + if (accountSelection && typeof accountSelection === 'object') { + const validStrategies = ['sticky', 'round-robin', 'hybrid']; + const acctUpdate = {}; + + if (accountSelection.strategy && validStrategies.includes(accountSelection.strategy)) { + acctUpdate.strategy = accountSelection.strategy; + } + + // Health score tuning + if (accountSelection.healthScore && typeof accountSelection.healthScore === 'object') { + const hs = accountSelection.healthScore; + const hsUpdate = {}; + if (typeof hs.initial === 'number' && hs.initial >= 0 && hs.initial <= 100) hsUpdate.initial = hs.initial; + if (typeof hs.successReward === 'number' && hs.successReward >= 0 && hs.successReward <= 20) hsUpdate.successReward = hs.successReward; + if (typeof hs.rateLimitPenalty === 'number' && hs.rateLimitPenalty >= -50 && hs.rateLimitPenalty <= 0) hsUpdate.rateLimitPenalty = hs.rateLimitPenalty; + if (typeof hs.failurePenalty === 'number' && hs.failurePenalty >= -50 && hs.failurePenalty <= 0) hsUpdate.failurePenalty = hs.failurePenalty; + if (typeof hs.recoveryPerHour === 'number' && hs.recoveryPerHour >= 0 && hs.recoveryPerHour <= 20) hsUpdate.recoveryPerHour = hs.recoveryPerHour; + if (typeof hs.minUsable === 'number' && hs.minUsable >= 0 && hs.minUsable <= 100) hsUpdate.minUsable = hs.minUsable; + if (typeof hs.maxScore === 'number' && hs.maxScore >= 1 && hs.maxScore <= 200) hsUpdate.maxScore = hs.maxScore; + if (Object.keys(hsUpdate).length > 0) acctUpdate.healthScore = hsUpdate; + } + + // Token bucket tuning + if (accountSelection.tokenBucket && typeof accountSelection.tokenBucket === 'object') { + const tb = accountSelection.tokenBucket; + const tbUpdate = {}; + if (typeof tb.maxTokens === 'number' && tb.maxTokens >= 5 && tb.maxTokens <= 200) tbUpdate.maxTokens = tb.maxTokens; + if (typeof tb.tokensPerMinute === 'number' && tb.tokensPerMinute >= 1 && tb.tokensPerMinute <= 60) tbUpdate.tokensPerMinute = tb.tokensPerMinute; + if (typeof tb.initialTokens === 'number' && tb.initialTokens >= 1 && tb.initialTokens <= 200) tbUpdate.initialTokens = tb.initialTokens; + if (Object.keys(tbUpdate).length > 0) acctUpdate.tokenBucket = tbUpdate; + } + + // Quota tuning + if (accountSelection.quota && typeof accountSelection.quota === 'object') { + const q = accountSelection.quota; + const qUpdate = {}; + if (typeof q.lowThreshold === 'number' && q.lowThreshold >= 0 && q.lowThreshold < 1) qUpdate.lowThreshold = q.lowThreshold; + if (typeof q.criticalThreshold === 'number' && q.criticalThreshold >= 0 && q.criticalThreshold < 1) qUpdate.criticalThreshold = q.criticalThreshold; + if (typeof q.staleMs === 'number' && q.staleMs >= 30000 && q.staleMs <= 3600000) qUpdate.staleMs = q.staleMs; + if (Object.keys(qUpdate).length > 0) acctUpdate.quota = qUpdate; + } + + // Weights tuning + if (accountSelection.weights && typeof accountSelection.weights === 'object') { + const w = accountSelection.weights; + const wUpdate = {}; + if (typeof w.health === 'number' && w.health >= 0 && w.health <= 20) wUpdate.health = w.health; + if (typeof w.tokens === 'number' && w.tokens >= 0 && w.tokens <= 20) wUpdate.tokens = w.tokens; + if (typeof w.quota === 'number' && w.quota >= 0 && w.quota <= 20) wUpdate.quota = w.quota; + if (typeof w.lru === 'number' && w.lru >= 0 && w.lru <= 5) wUpdate.lru = w.lru; + if (Object.keys(wUpdate).length > 0) acctUpdate.weights = wUpdate; + } + + if (Object.keys(acctUpdate).length > 0) { + updates.accountSelection = acctUpdate; + } + } + + return updates; +} + +/** + * Mount WebUI routes and middleware on Express app + * @param {Express} app - Express application instance + * @param {string} dirname - __dirname of the calling module (for static file path) + * @param {AccountManager} accountManager - Account manager instance + */ +export function mountWebUI(app, dirname, accountManager) { + // Apply auth middleware + app.use(createAuthMiddleware()); + + // Serve static files from public directory + app.use(express.static(path.join(dirname, '../public'))); + + // ========================================== + // Account Management API + // ========================================== + + /** + * GET /api/accounts - List all accounts with status + */ + app.get('/api/accounts', async (req, res) => { + try { + const status = accountManager.getStatus(); + res.json({ + status: 'ok', + accounts: status.accounts, + summary: { + total: status.total, + available: status.available, + rateLimited: status.rateLimited, + invalid: status.invalid + } + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/accounts/:email/refresh - Refresh specific account token + */ + app.post('/api/accounts/:email/refresh', async (req, res) => { + try { + const { email } = req.params; + accountManager.clearTokenCache(email); + accountManager.clearProjectCache(email); + + // For verification errors (403 VALIDATION_REQUIRED), clear isInvalid on refresh. + // The user has completed verification on Google's site and clicks Refresh to re-enable. + // Auth errors (no verifyUrl) still require OAuth re-auth via FIX button. + const account = accountManager.getAllAccounts().find(a => a.email === email); + if (account && account.isInvalid && account.verifyUrl) { + accountManager.clearInvalid(email); + } + + res.json({ + status: 'ok', + message: `Token cache cleared for ${email}` + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/accounts/:email/toggle - Enable/disable account + */ + app.post('/api/accounts/:email/toggle', async (req, res) => { + try { + const { email } = req.params; + const { enabled } = req.body; + + if (typeof enabled !== 'boolean') { + return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' }); + } + + await setAccountEnabled(email, enabled); + + // Reload AccountManager to pick up changes + await accountManager.reload(); + + res.json({ + status: 'ok', + message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}` + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * DELETE /api/accounts/:email - Remove account + */ + app.delete('/api/accounts/:email', async (req, res) => { + try { + const { email } = req.params; + await removeAccount(email); + + // Reload AccountManager to pick up changes + await accountManager.reload(); + + res.json({ + status: 'ok', + message: `Account ${email} removed` + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * PATCH /api/accounts/:email - Update account settings (thresholds) + */ + app.patch('/api/accounts/:email', async (req, res) => { + try { + const { email } = req.params; + const { quotaThreshold, modelQuotaThresholds } = req.body; + + const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); + const account = accounts.find(a => a.email === email); + + if (!account) { + return res.status(404).json({ status: 'error', error: `Account ${email} not found` }); + } + + // Validate and update quotaThreshold (0-0.99 or null/undefined to clear) + if (quotaThreshold !== undefined) { + if (quotaThreshold === null) { + delete account.quotaThreshold; + } else if (typeof quotaThreshold === 'number' && quotaThreshold >= 0 && quotaThreshold < 1) { + account.quotaThreshold = quotaThreshold; + } else { + return res.status(400).json({ status: 'error', error: 'quotaThreshold must be 0-0.99 or null' }); + } + } + + // Validate and update modelQuotaThresholds (full replacement, not merge) + if (modelQuotaThresholds !== undefined) { + if (modelQuotaThresholds === null || (typeof modelQuotaThresholds === 'object' && Object.keys(modelQuotaThresholds).length === 0)) { + // Clear all model thresholds + delete account.modelQuotaThresholds; + } else if (typeof modelQuotaThresholds === 'object') { + // Validate all thresholds first + for (const [modelId, threshold] of Object.entries(modelQuotaThresholds)) { + if (typeof threshold !== 'number' || threshold < 0 || threshold >= 1) { + return res.status(400).json({ + status: 'error', + error: `Invalid threshold for model ${modelId}: must be 0-0.99` + }); + } + } + // Replace entire object (not merge) + account.modelQuotaThresholds = { ...modelQuotaThresholds }; + } else { + return res.status(400).json({ status: 'error', error: 'modelQuotaThresholds must be an object or null' }); + } + } + + await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex); + + // Reload AccountManager to pick up changes + await accountManager.reload(); + + logger.info(`[WebUI] Account ${email} thresholds updated`); + + res.json({ + status: 'ok', + message: `Account ${email} thresholds updated`, + account: { + email: account.email, + quotaThreshold: account.quotaThreshold, + modelQuotaThresholds: account.modelQuotaThresholds || {} + } + }); + } catch (error) { + logger.error('[WebUI] Error updating account thresholds:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/accounts/reload - Reload accounts from disk + */ + app.post('/api/accounts/reload', async (req, res) => { + try { + // Reload AccountManager from disk + await accountManager.reload(); + + const status = accountManager.getStatus(); + res.json({ + status: 'ok', + message: 'Accounts reloaded from disk', + summary: status.summary + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * GET /api/accounts/export - Export accounts + */ + app.get('/api/accounts/export', async (req, res) => { + try { + const { accounts } = await loadAccounts(ACCOUNT_CONFIG_PATH); + + // Export only essential fields for portability + const exportData = accounts + .filter(acc => acc.source !== 'database') + .map(acc => { + const essential = { email: acc.email }; + // Use snake_case for compatibility + if (acc.refreshToken) { + essential.refresh_token = acc.refreshToken; + } + if (acc.apiKey) { + essential.api_key = acc.apiKey; + } + return essential; + }); + + // Return plain array for simpler format + res.json(exportData); + } catch (error) { + logger.error('[WebUI] Export accounts error:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/accounts/import - Batch import accounts + */ + app.post('/api/accounts/import', async (req, res) => { + try { + // Support both wrapped format { accounts: [...] } and plain array [...] + let importAccounts = req.body; + if (req.body.accounts && Array.isArray(req.body.accounts)) { + importAccounts = req.body.accounts; + } + + if (!Array.isArray(importAccounts) || importAccounts.length === 0) { + return res.status(400).json({ + status: 'error', + error: 'accounts must be a non-empty array' + }); + } + + const results = { added: [], updated: [], failed: [] }; + + // Load existing accounts once before the loop + const { accounts: existingAccounts } = await loadAccounts(ACCOUNT_CONFIG_PATH); + const existingEmails = new Set(existingAccounts.map(a => a.email)); + + for (const acc of importAccounts) { + try { + // Validate required fields + if (!acc.email) { + results.failed.push({ email: acc.email || 'unknown', reason: 'Missing email' }); + continue; + } + + // Support both snake_case and camelCase + const refreshToken = acc.refresh_token || acc.refreshToken; + const apiKey = acc.api_key || acc.apiKey; + + // Must have at least one credential + if (!refreshToken && !apiKey) { + results.failed.push({ email: acc.email, reason: 'Missing refresh_token or api_key' }); + continue; + } + + // Check if account already exists + const exists = existingEmails.has(acc.email); + + // Add account + await addAccount({ + email: acc.email, + source: apiKey ? 'manual' : 'oauth', + refreshToken: refreshToken, + apiKey: apiKey + }); + + if (exists) { + results.updated.push(acc.email); + } else { + results.added.push(acc.email); + } + } catch (err) { + results.failed.push({ email: acc.email, reason: err.message }); + } + } + + // Reload AccountManager + await accountManager.reload(); + + logger.info(`[WebUI] Import complete: ${results.added.length} added, ${results.updated.length} updated, ${results.failed.length} failed`); + + res.json({ + status: 'ok', + results, + message: `Imported ${results.added.length + results.updated.length} accounts` + }); + } catch (error) { + logger.error('[WebUI] Import accounts error:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + // ========================================== + // Configuration API + // ========================================== + + /** + * GET /api/config - Get server configuration + */ + app.get('/api/config', (req, res) => { + try { + const publicConfig = getPublicConfig(); + res.json({ + status: 'ok', + config: publicConfig, + version: packageVersion, + note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values' + }); + } catch (error) { + logger.error('[WebUI] Error getting config:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/config - Update server configuration + */ + app.post('/api/config', async (req, res) => { + try { + const { debug, devMode, logLevel, persistTokenCache, requestThrottlingEnabled, requestDelayMs } = req.body; + + // Validate tunable config fields via shared helper + const updates = validateConfigFields(req.body); + + // Handle fields not covered by the shared helper + if (typeof devMode === 'boolean') { + updates.devMode = devMode; + updates.debug = devMode; + logger.setDebug(devMode); + } else if (typeof debug === 'boolean') { + updates.debug = debug; + updates.devMode = debug; + logger.setDebug(debug); + } + if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) { + updates.logLevel = logLevel; + } + if (typeof persistTokenCache === 'boolean') { + updates.persistTokenCache = persistTokenCache; + } + if (typeof requestThrottlingEnabled === 'boolean') { + updates.requestThrottlingEnabled = requestThrottlingEnabled; + } + if (typeof requestDelayMs === 'number' && requestDelayMs >= 100 && requestDelayMs <= 5000) { + updates.requestDelayMs = requestDelayMs; + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ + status: 'error', + error: 'No valid configuration updates provided' + }); + } + + const success = saveConfig(updates); + + if (success) { + // Hot-reload strategy if it was changed (no server restart needed) + if (updates.accountSelection?.strategy && accountManager) { + await accountManager.reload(); + logger.info(`[WebUI] Strategy hot-reloaded to: ${updates.accountSelection.strategy}`); + } + + res.json({ + status: 'ok', + message: 'Configuration saved. Restart server to apply some changes.', + updates: updates, + config: getPublicConfig() + }); + } else { + res.status(500).json({ + status: 'error', + error: 'Failed to save configuration file' + }); + } + } catch (error) { + logger.error('[WebUI] Error updating config:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/config/password - Change WebUI password + */ + app.post('/api/config/password', (req, res) => { + try { + const { oldPassword, newPassword } = req.body; + + // Validate input + if (!newPassword || typeof newPassword !== 'string') { + return res.status(400).json({ + status: 'error', + error: 'New password is required' + }); + } + + // If current password exists, verify old password + if (config.webuiPassword && config.webuiPassword !== oldPassword) { + return res.status(403).json({ + status: 'error', + error: 'Invalid current password' + }); + } + + // Save new password + const success = saveConfig({ webuiPassword: newPassword }); + + if (success) { + // Update in-memory config + config.webuiPassword = newPassword; + res.json({ + status: 'ok', + message: 'Password changed successfully' + }); + } else { + throw new Error('Failed to save password to config file'); + } + } catch (error) { + logger.error('[WebUI] Error changing password:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * GET /api/settings - Get runtime settings + */ + app.get('/api/settings', async (req, res) => { + try { + const settings = accountManager.getSettings ? accountManager.getSettings() : {}; + res.json({ + status: 'ok', + settings: { + ...settings, + port: process.env.PORT || DEFAULT_PORT + } + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + // ========================================== + // Claude CLI Configuration API + // ========================================== + + /** + * GET /api/claude/config - Get Claude CLI configuration + */ + app.get('/api/claude/config', async (req, res) => { + try { + const claudeConfig = await readClaudeConfig(); + res.json({ + status: 'ok', + config: claudeConfig, + path: getClaudeConfigPath() + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/claude/config - Update Claude CLI configuration + */ + app.post('/api/claude/config', async (req, res) => { + try { + const updates = req.body; + if (!updates || typeof updates !== 'object') { + return res.status(400).json({ status: 'error', error: 'Invalid config updates' }); + } + + const newConfig = await updateClaudeConfig(updates); + res.json({ + status: 'ok', + config: newConfig, + message: 'Claude configuration updated' + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/claude/config/restore - Restore Claude CLI to default (remove proxy settings) + */ + app.post('/api/claude/config/restore', async (req, res) => { + try { + const claudeConfig = await readClaudeConfig(); + + // Proxy-related environment variables to remove when restoring defaults + const PROXY_ENV_VARS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ENABLE_EXPERIMENTAL_MCP_CLI' + ]; + + // Remove proxy-related environment variables to restore defaults + if (claudeConfig.env) { + for (const key of PROXY_ENV_VARS) { + delete claudeConfig.env[key]; + } + // Remove env entirely if empty to truly restore defaults + if (Object.keys(claudeConfig.env).length === 0) { + delete claudeConfig.env; + } + } + + // Use replaceClaudeConfig to completely overwrite the config (not merge) + const newConfig = await replaceClaudeConfig(claudeConfig); + + logger.info(`[WebUI] Restored Claude CLI config to defaults at ${getClaudeConfigPath()}`); + + res.json({ + status: 'ok', + config: newConfig, + message: 'Claude CLI configuration restored to defaults' + }); + } catch (error) { + logger.error('[WebUI] Error restoring Claude config:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + // ========================================== + // Claude CLI Mode Toggle API (Proxy/Paid) + // ========================================== + + /** + * GET /api/claude/mode - Get current mode (proxy or paid) + * Returns 'proxy' if ANTHROPIC_BASE_URL is set to localhost, 'paid' otherwise + */ + app.get('/api/claude/mode', async (req, res) => { + try { + const claudeConfig = await readClaudeConfig(); + const baseUrl = claudeConfig.env?.ANTHROPIC_BASE_URL || ''; + + // Determine mode based on ANTHROPIC_BASE_URL + const isProxy = baseUrl && ( + baseUrl.includes('localhost') || + baseUrl.includes('127.0.0.1') || + baseUrl.includes('::1') || + baseUrl.includes('0.0.0.0') + ); + + res.json({ + status: 'ok', + mode: isProxy ? 'proxy' : 'paid' + }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/claude/mode - Switch between proxy and paid mode + * Body: { mode: 'proxy' | 'paid' } + * + * When switching to 'paid' mode: + * - Removes the entire 'env' object from settings.json + * - Claude CLI uses its built-in defaults (official Anthropic API) + * + * When switching to 'proxy' mode: + * - Sets 'env' to the first default preset config (from constants.js) + */ + app.post('/api/claude/mode', async (req, res) => { + try { + const { mode } = req.body; + + if (!mode || !['proxy', 'paid'].includes(mode)) { + return res.status(400).json({ + status: 'error', + error: 'mode must be "proxy" or "paid"' + }); + } + + const claudeConfig = await readClaudeConfig(); + + if (mode === 'proxy') { + // Switch to proxy mode - use first default preset config (e.g., "Claude Thinking") + claudeConfig.env = { ...DEFAULT_PRESETS[0].config }; + } else { + // Switch to paid mode - remove env entirely + delete claudeConfig.env; + } + + // Save the updated config + const newConfig = await replaceClaudeConfig(claudeConfig); + + logger.info(`[WebUI] Switched Claude CLI to ${mode} mode`); + + res.json({ + status: 'ok', + mode, + config: newConfig, + message: `Switched to ${mode === 'proxy' ? 'Proxy' : 'Paid (Anthropic API)'} mode. Restart Claude CLI to apply.` + }); + } catch (error) { + logger.error('[WebUI] Error switching mode:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + // ========================================== + // Claude CLI Presets API + // ========================================== + + + /** + * GET /api/claude/presets - Get all saved presets + */ + app.get('/api/claude/presets', async (req, res) => { + try { + const presets = await readPresets(); + res.json({ status: 'ok', presets }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/claude/presets - Save a new preset + */ + app.post('/api/claude/presets', async (req, res) => { + try { + const { name, config: presetConfig } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ status: 'error', error: 'Preset name is required' }); + } + if (!presetConfig || typeof presetConfig !== 'object') { + return res.status(400).json({ status: 'error', error: 'Config object is required' }); + } + + const presets = await savePreset(name.trim(), presetConfig); + res.json({ status: 'ok', presets, message: `Preset "${name}" saved` }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * DELETE /api/claude/presets/:name - Delete a preset + */ + app.delete('/api/claude/presets/:name', async (req, res) => { + try { + const { name } = req.params; + if (!name) { + return res.status(400).json({ status: 'error', error: 'Preset name is required' }); + } + + const presets = await deletePreset(name); + res.json({ status: 'ok', presets, message: `Preset "${name}" deleted` }); + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + // ========================================== + // Server Configuration Presets API + // ========================================== + + /** + * GET /api/server/presets - List all server config presets + */ + app.get('/api/server/presets', async (req, res) => { + try { + const presets = await readServerPresets(); + res.json({ status: 'ok', presets }); + } catch (error) { + logger.error('[WebUI] Error reading server presets:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/server/presets - Save a custom server config preset + */ + app.post('/api/server/presets', async (req, res) => { + try { + const { name, config: presetConfig, description } = req.body; + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ status: 'error', error: 'Preset name is required' }); + } + if (name.trim().length > 50) { + return res.status(400).json({ status: 'error', error: 'Preset name must be 50 characters or fewer' }); + } + if (!presetConfig || typeof presetConfig !== 'object' || Array.isArray(presetConfig)) { + return res.status(400).json({ status: 'error', error: 'Config object is required' }); + } + + const validatedConfig = validateConfigFields(presetConfig); + if (Object.keys(validatedConfig).length === 0) { + return res.status(400).json({ status: 'error', error: 'No valid config fields provided' }); + } + + const presets = await saveServerPreset(name.trim(), validatedConfig, description); + res.json({ status: 'ok', presets, message: `Server preset "${name}" saved` }); + } catch (error) { + const status = error.message.includes('built-in') ? 400 : 500; + res.status(status).json({ status: 'error', error: error.message }); + } + }); + + /** + * PATCH /api/server/presets/:name - Update custom preset metadata and/or config + */ + app.patch('/api/server/presets/:name', async (req, res) => { + try { + const { name: currentName } = req.params; + if (!currentName) { + return res.status(400).json({ status: 'error', error: 'Preset name is required' }); + } + + const { name: newName, description, config: configInput } = req.body; + if (typeof newName === 'string' && !newName.trim()) { + return res.status(400).json({ status: 'error', error: 'Preset name is required' }); + } + if (typeof newName === 'string' && newName.trim().length > 50) { + return res.status(400).json({ status: 'error', error: 'Preset name must be 50 characters or fewer' }); + } + const updates = {}; + if (newName !== undefined) updates.name = newName.trim(); + if (description !== undefined) updates.description = description; + + // Validate and include config updates if provided + if (configInput && typeof configInput === 'object') { + const validatedConfig = validateConfigFields(configInput); + if (Object.keys(validatedConfig).length > 0) { + updates.config = validatedConfig; + } + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ status: 'error', error: 'No updates provided' }); + } + + const presets = await updateServerPreset(currentName, updates); + res.json({ status: 'ok', presets, message: `Server preset "${currentName}" updated` }); + } catch (error) { + const status = error.message.includes('built-in') || error.message.includes('not found') || error.message.includes('already exists') ? 400 : 500; + res.status(status).json({ status: 'error', error: error.message }); + } + }); + + /** + * DELETE /api/server/presets/:name - Delete a custom server config preset + */ + app.delete('/api/server/presets/:name', async (req, res) => { + try { + const { name } = req.params; + if (!name) { + return res.status(400).json({ status: 'error', error: 'Preset name is required' }); + } + + const presets = await deleteServerPreset(name); + res.json({ status: 'ok', presets, message: `Server preset "${name}" deleted` }); + } catch (error) { + const status = error.message.includes('built-in') ? 400 : 500; + res.status(status).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/models/config - Update model configuration (hidden/pinned/alias) + */ + app.post('/api/models/config', (req, res) => { + try { + const { modelId, config: newModelConfig } = req.body; + + if (!modelId || typeof newModelConfig !== 'object') { + return res.status(400).json({ status: 'error', error: 'Invalid parameters' }); + } + + // Load current config + const currentMapping = config.modelMapping || {}; + + // Update specific model config + currentMapping[modelId] = { + ...currentMapping[modelId], + ...newModelConfig + }; + + // Save back to main config + const success = saveConfig({ modelMapping: currentMapping }); + + if (success) { + // Update in-memory config reference + config.modelMapping = currentMapping; + res.json({ status: 'ok', modelConfig: currentMapping[modelId] }); + } else { + throw new Error('Failed to save configuration'); + } + } catch (error) { + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + // ========================================== + // Logs API + // ========================================== + + /** + * GET /api/logs - Get log history + */ + app.get('/api/logs', (req, res) => { + res.json({ + status: 'ok', + logs: logger.getHistory ? logger.getHistory() : [] + }); + }); + + /** + * GET /api/logs/stream - Stream logs via SSE + */ + app.get('/api/logs/stream', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const sendLog = (log) => { + res.write(`data: ${JSON.stringify(log)}\n\n`); + }; + + // Send recent history if requested + if (req.query.history === 'true' && logger.getHistory) { + const history = logger.getHistory(); + history.forEach(log => sendLog(log)); + } + + // Subscribe to new logs + if (logger.on) { + logger.on('log', sendLog); + } + + // Cleanup on disconnect + req.on('close', () => { + if (logger.off) { + logger.off('log', sendLog); + } + }); + }); + + // ========================================== + // Strategy Health API (Developer Mode) + // ========================================== + + /** + * GET /api/strategy/health - Get strategy health data for the inspector panel + * Only available when devMode is enabled + */ + app.get('/api/strategy/health', (req, res) => { + try { + if (!config.devMode) { + return res.status(403).json({ + status: 'error', + error: 'Developer mode is not enabled' + }); + } + + const healthData = accountManager.getStrategyHealthData(); + res.json({ + status: 'ok', + ...healthData + }); + } catch (error) { + logger.error('[WebUI] Error fetching strategy health:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + // ========================================== + // OAuth API + // ========================================== + + /** + * GET /api/auth/url - Get OAuth URL to start the flow + * Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port + * to match Google OAuth Console's authorized redirect URIs + */ + app.get('/api/auth/url', async (req, res) => { + try { + // Clean up old flows (> 10 mins) + const now = Date.now(); + for (const [key, val] of pendingOAuthFlows.entries()) { + if (now - val.timestamp > 10 * 60 * 1000) { + pendingOAuthFlows.delete(key); + } + } + + // Generate OAuth URL using default redirect URI (localhost:51121) + const { url, verifier, state } = getAuthorizationUrl(); + + // Start callback server on port 51121 (same as CLI) + const { promise: serverPromise, abort: abortServer } = startCallbackServer(state, 120000); // 2 min timeout + + // Store the flow data + pendingOAuthFlows.set(state, { + serverPromise, + abortServer, + verifier, + state, + timestamp: Date.now() + }); + + // Start async handler for the OAuth callback + serverPromise + .then(async (code) => { + try { + logger.info('[WebUI] Received OAuth callback, completing flow...'); + const accountData = await completeOAuthFlow(code, verifier); + + // Add or update the account + // Note: Don't set projectId here - it will be discovered and stored + // in the refresh token via getProjectForAccount() on first use + await addAccount({ + email: accountData.email, + refreshToken: accountData.refreshToken, + source: 'oauth' + }); + + // Reload AccountManager to pick up the new account + await accountManager.reload(); + + logger.success(`[WebUI] Account ${accountData.email} added successfully`); + } catch (err) { + logger.error('[WebUI] OAuth flow completion error:', err); + } finally { + pendingOAuthFlows.delete(state); + } + }) + .catch((err) => { + // Only log if not aborted (manual completion causes this) + if (!err.message?.includes('aborted')) { + logger.error('[WebUI] OAuth callback server error:', err); + } + pendingOAuthFlows.delete(state); + }); + + res.json({ status: 'ok', url, state }); + } catch (error) { + logger.error('[WebUI] Error generating auth URL:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * POST /api/auth/complete - Complete OAuth with manually submitted callback URL/code + * Used when auto-callback cannot reach the local server + */ + app.post('/api/auth/complete', async (req, res) => { + try { + const { callbackInput, state } = req.body; + + if (!callbackInput || !state) { + return res.status(400).json({ + status: 'error', + error: 'Missing callbackInput or state' + }); + } + + // Find the pending flow + const flowData = pendingOAuthFlows.get(state); + if (!flowData) { + return res.status(400).json({ + status: 'error', + error: 'OAuth flow not found. The account may have been already added via auto-callback. Please refresh the account list.' + }); + } + + const { verifier, abortServer } = flowData; + + // Extract code from input (URL or raw code) + const { extractCodeFromInput, completeOAuthFlow } = await import('../auth/oauth.js'); + const { code } = extractCodeFromInput(callbackInput); + + // Complete the OAuth flow + const accountData = await completeOAuthFlow(code, verifier); + + // Add or update the account + await addAccount({ + email: accountData.email, + refreshToken: accountData.refreshToken, + projectId: accountData.projectId, + source: 'oauth' + }); + + // Reload AccountManager to pick up the new account + await accountManager.reload(); + + // Abort the callback server since manual completion succeeded + if (abortServer) { + abortServer(); + } + + // Clean up + pendingOAuthFlows.delete(state); + + logger.success(`[WebUI] Account ${accountData.email} added via manual callback`); + + res.json({ + status: 'ok', + email: accountData.email, + message: `Account ${accountData.email} added successfully` + }); + } catch (error) { + logger.error('[WebUI] Manual OAuth completion error:', error); + res.status(500).json({ status: 'error', error: error.message }); + } + }); + + /** + * Note: /oauth/callback route removed + * OAuth callbacks are now handled by the temporary server on port 51121 + * (same as CLI) to match Google OAuth Console's authorized redirect URIs + */ + + logger.info('[WebUI] Mounted at /'); +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100755 index 0000000000000000000000000000000000000000..b0a60697e9d0f634ef0ba535cc8b80d1b41f35f0 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,51 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./public/**/*.{html,js}" // Simplified: already covers all subdirectories + ], + darkMode: 'class', + theme: { + extend: { + fontFamily: { + mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'], + sans: ['Inter', 'system-ui', 'sans-serif'] + }, + colors: { + space: { + 950: '#09090b', + 900: '#0f0f11', + 850: '#121214', + 800: '#18181b', + border: '#27272a' + }, + neon: { + purple: '#a855f7', + cyan: '#06b6d4', + green: '#22c55e', + yellow: '#eab308', + red: '#ef4444' + } + } + } + }, + plugins: [ + require('@tailwindcss/forms'), + require('daisyui') + ], + daisyui: { + themes: [{ + antigravity: { + "primary": "#a855f7", // neon-purple + "secondary": "#22c55e", // neon-green + "accent": "#06b6d4", // neon-cyan + "neutral": "#18181b", // space-800 + "base-100": "#09090b", // space-950 + "info": "#06b6d4", // neon-cyan + "success": "#22c55e", // neon-green + "warning": "#eab308", // neon-yellow + "error": "#ef4444", // neon-red + } + }], + logs: false // Disable console logs in production + } +} diff --git a/tests/frontend/test-frontend-accounts.cjs b/tests/frontend/test-frontend-accounts.cjs new file mode 100755 index 0000000000000000000000000000000000000000..8c110683ccb43854ee3e89f8094a952719b5cd35 --- /dev/null +++ b/tests/frontend/test-frontend-accounts.cjs @@ -0,0 +1,294 @@ +/** + * Frontend Test Suite - Accounts Page + * Tests the account manager component functionality + * + * Run: node tests/test-frontend-accounts.cjs + */ + +const http = require('http'); + +const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +function request(path, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(path, BASE_URL); + const req = http.request(url, { + method: options.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + resolve({ status: res.statusCode, data, headers: res.headers }); + }); + }); + req.on('error', reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +const tests = [ + { + name: 'Accounts view loads successfully', + async run() { + const res = await request('/views/accounts.html'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + if (!res.data.includes('x-data="accountManager"')) { + throw new Error('AccountManager component not found'); + } + return 'Accounts HTML loads with component'; + } + }, + { + name: 'Accounts API endpoint exists', + async run() { + const res = await request('/api/accounts'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.accounts || !Array.isArray(data.accounts)) { + throw new Error('accounts array not found in response'); + } + if (!data.summary) { + throw new Error('summary object not found in response'); + } + return `API returns ${data.accounts.length} accounts`; + } + }, + { + name: 'Accounts view has table with required columns', + async run() { + const res = await request('/views/accounts.html'); + // Updated column names to match current HTML: enabled, accountEmail, source, tier, quota, health, operations + const columns = ['enabled', 'accountEmail', 'source', 'tier', 'health', 'operations']; + + const missing = columns.filter(col => !res.data.includes(col)); + if (missing.length > 0) { + throw new Error(`Missing columns: ${missing.join(', ')}`); + } + return 'All table columns present'; + } + }, + { + name: 'Accounts view has toggle switch', + async run() { + const res = await request('/views/accounts.html'); + if (!res.data.includes('toggleAccount')) { + throw new Error('Toggle account function not found'); + } + if (!res.data.includes('acc.enabled')) { + throw new Error('Enabled state binding not found'); + } + return 'Account toggle switch present'; + } + }, + { + name: 'Accounts view has refresh button', + async run() { + const res = await request('/views/accounts.html'); + if (!res.data.includes('refreshAccount')) { + throw new Error('Refresh account function not found'); + } + return 'Refresh button present'; + } + }, + { + name: 'Accounts view has delete button', + async run() { + const res = await request('/views/accounts.html'); + // Function is now confirmDeleteAccount (opens confirmation modal) + if (!res.data.includes('confirmDeleteAccount')) { + throw new Error('Delete account function not found'); + } + return 'Delete button present'; + } + }, + { + name: 'Accounts view has fix/re-auth button', + async run() { + const res = await request('/views/accounts.html'); + if (!res.data.includes('fixAccount')) { + throw new Error('Fix account function not found'); + } + return 'Fix/re-auth button present'; + } + }, + { + name: 'Accounts view has Add Node button', + async run() { + const res = await request('/views/accounts.html'); + if (!res.data.includes('addNode') && !res.data.includes('add_account_modal')) { + throw new Error('Add account button not found'); + } + return 'Add Node button present'; + } + }, + { + name: 'Main page has addAccountModal component', + async run() { + // The modal lives in index.html, not accounts.html + const res = await request('/index.html'); + if (!res.data.includes('addAccountModal')) { + throw new Error('addAccountModal component not found'); + } + return 'addAccountModal component present'; + } + }, + { + name: 'Main page has manual auth UI elements', + async run() { + // Manual auth elements are in the modal in index.html + const res = await request('/index.html'); + const elements = ['initManualAuth', 'completeManualAuth', 'callbackInput']; + const missing = elements.filter(el => !res.data.includes(el)); + if (missing.length > 0) { + throw new Error(`Missing manual auth elements: ${missing.join(', ')}`); + } + return 'All manual auth UI elements present'; + } + }, + { + name: 'Auth URL API endpoint works', + async run() { + const res = await request('/api/auth/url'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (data.status !== 'ok') { + throw new Error(`API returned status: ${data.status}`); + } + if (!data.url || !data.state) { + throw new Error('Missing url or state in response'); + } + return `Auth URL generated with state: ${data.state.substring(0, 8)}...`; + } + }, + { + name: 'Auth complete API validates required fields', + async run() { + const res = await request('/api/auth/complete', { + method: 'POST', + body: {} + }); + if (res.status !== 400) { + throw new Error(`Expected 400 for missing fields, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.error || !data.error.includes('Missing')) { + throw new Error('Expected error about missing fields'); + } + return 'API validates required fields'; + } + }, + { + name: 'Auth complete API rejects invalid state', + async run() { + const res = await request('/api/auth/complete', { + method: 'POST', + body: { callbackInput: 'fake-code', state: 'invalid-state' } + }); + if (res.status !== 400) { + throw new Error(`Expected 400 for invalid state, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.error || !data.error.includes('not found')) { + throw new Error('Expected error about flow not found'); + } + return 'API rejects invalid state'; + } + }, + { + name: 'Account toggle API works', + async run() { + // First get an account + const accountsRes = await request('/api/accounts'); + const accounts = JSON.parse(accountsRes.data).accounts; + + if (accounts.length === 0) { + return 'Skipped: No accounts to test'; + } + + const email = accounts[0].email; + const currentEnabled = accounts[0].isInvalid !== true; + + // Toggle the account (this is a real API call, be careful) + const toggleRes = await request(`/api/accounts/${encodeURIComponent(email)}/toggle`, { + method: 'POST', + body: { enabled: !currentEnabled } + }); + + if (toggleRes.status !== 200) { + throw new Error(`Toggle failed with status ${toggleRes.status}`); + } + + // Toggle back to original state + await request(`/api/accounts/${encodeURIComponent(email)}/toggle`, { + method: 'POST', + body: { enabled: currentEnabled } + }); + + return `Toggle API works for ${email.split('@')[0]}`; + } + }, + { + name: 'Account refresh API works', + async run() { + const accountsRes = await request('/api/accounts'); + const accounts = JSON.parse(accountsRes.data).accounts; + + if (accounts.length === 0) { + return 'Skipped: No accounts to test'; + } + + const email = accounts[0].email; + const refreshRes = await request(`/api/accounts/${encodeURIComponent(email)}/refresh`, { + method: 'POST' + }); + + if (refreshRes.status !== 200) { + throw new Error(`Refresh failed with status ${refreshRes.status}`); + } + + return `Refresh API works for ${email.split('@')[0]}`; + } + } +]; + +async function runTests() { + console.log('🧪 Accounts Frontend Tests\n'); + console.log('='.repeat(50)); + + let passed = 0; + let failed = 0; + + for (const test of tests) { + try { + const result = await test.run(); + console.log(`✅ ${test.name}`); + console.log(` ${result}\n`); + passed++; + } catch (error) { + console.log(`❌ ${test.name}`); + console.log(` Error: ${error.message}\n`); + failed++; + } + } + + console.log('='.repeat(50)); + console.log(`Results: ${passed} passed, ${failed} failed`); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch(err => { + console.error('Test runner failed:', err); + process.exit(1); +}); diff --git a/tests/frontend/test-frontend-all.cjs b/tests/frontend/test-frontend-all.cjs new file mode 100755 index 0000000000000000000000000000000000000000..64baf80989e77d2d86b17218a658e6ac6d7e6347 --- /dev/null +++ b/tests/frontend/test-frontend-all.cjs @@ -0,0 +1,85 @@ +/** + * Frontend Test Runner + * Runs all frontend test suites + * + * Run: node tests/frontend/test-frontend-all.cjs + */ + +const { execSync, spawn } = require('child_process'); +const path = require('path'); + +const testFiles = [ + 'test-frontend-dashboard.cjs', + 'test-frontend-logs.cjs', + 'test-frontend-accounts.cjs', + 'test-frontend-settings.cjs' +]; + +async function runTests() { + console.log('🚀 Running All Frontend Tests\n'); + console.log('═'.repeat(60)); + + let totalPassed = 0; + let totalFailed = 0; + const results = []; + + for (const testFile of testFiles) { + const testPath = path.join(__dirname, testFile); + console.log(`\n📋 Running: ${testFile}`); + console.log('─'.repeat(60)); + + try { + const output = execSync(`node "${testPath}"`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + console.log(output); + + // Parse results from output + const match = output.match(/Results: (\d+) passed, (\d+) failed/); + if (match) { + const passed = parseInt(match[1]); + const failed = parseInt(match[2]); + totalPassed += passed; + totalFailed += failed; + results.push({ file: testFile, passed, failed, status: 'completed' }); + } + } catch (error) { + console.log(error.stdout || ''); + console.log(error.stderr || ''); + + // Try to parse results even on failure + const output = error.stdout || ''; + const match = output.match(/Results: (\d+) passed, (\d+) failed/); + if (match) { + const passed = parseInt(match[1]); + const failed = parseInt(match[2]); + totalPassed += passed; + totalFailed += failed; + results.push({ file: testFile, passed, failed, status: 'completed with errors' }); + } else { + results.push({ file: testFile, passed: 0, failed: 1, status: 'crashed' }); + totalFailed++; + } + } + } + + console.log('\n' + '═'.repeat(60)); + console.log('📊 SUMMARY\n'); + + for (const result of results) { + const icon = result.failed === 0 ? '✅' : '❌'; + console.log(`${icon} ${result.file}: ${result.passed} passed, ${result.failed} failed (${result.status})`); + } + + console.log('\n' + '─'.repeat(60)); + console.log(`Total: ${totalPassed} passed, ${totalFailed} failed`); + console.log('═'.repeat(60)); + + process.exit(totalFailed > 0 ? 1 : 0); +} + +runTests().catch(err => { + console.error('Test runner crashed:', err); + process.exit(1); +}); diff --git a/tests/frontend/test-frontend-dashboard.cjs b/tests/frontend/test-frontend-dashboard.cjs new file mode 100755 index 0000000000000000000000000000000000000000..61b387d65fbe53d6493eab915beb6a1e84803fba --- /dev/null +++ b/tests/frontend/test-frontend-dashboard.cjs @@ -0,0 +1,162 @@ +/** + * Frontend Test Suite - Dashboard Page + * Tests the dashboard component functionality + * + * Run: node tests/test-frontend-dashboard.cjs + */ + +const http = require('http'); + +const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +// Helper to make HTTP requests +function request(path, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(path, BASE_URL); + const req = http.request(url, { + method: options.method || 'GET', + headers: options.headers || {} + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + resolve({ status: res.statusCode, data, headers: res.headers }); + }); + }); + req.on('error', reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +// Test cases +const tests = [ + { + name: 'Dashboard view loads successfully', + async run() { + const res = await request('/views/dashboard.html'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + if (!res.data.includes('x-data="dashboard"')) { + throw new Error('Dashboard component not found in HTML'); + } + if (!res.data.includes('quotaChart')) { + throw new Error('Quota chart canvas not found'); + } + return 'Dashboard HTML loads with component and chart'; + } + }, + { + name: 'Account limits API returns data', + async run() { + const res = await request('/account-limits'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.accounts || !Array.isArray(data.accounts)) { + throw new Error('accounts array not found in response'); + } + if (!data.models || !Array.isArray(data.models)) { + throw new Error('models array not found in response'); + } + return `API returns ${data.accounts.length} accounts and ${data.models.length} models`; + } + }, + { + name: 'Dashboard has stats grid elements', + async run() { + const res = await request('/views/dashboard.html'); + const html = res.data; + + const requiredElements = [ + 'totalAccounts', // Total accounts stat + 'stats.total', // Total stat binding + 'stats.active', // Active stat binding + 'stats.limited', // Limited stat binding + 'quotaChart' // Chart canvas + ]; + + const missing = requiredElements.filter(el => !html.includes(el)); + if (missing.length > 0) { + throw new Error(`Missing elements: ${missing.join(', ')}`); + } + return 'All required dashboard elements present'; + } + }, + { + name: 'Dashboard has filter controls', + async run() { + const res = await request('/views/dashboard.html'); + const html = res.data; + + // Dashboard uses dropdown-based filters for time range, display mode, and model selection + const filterElements = [ + 'showTimeRangeDropdown', // Time range dropdown toggle + 'showDisplayModeDropdown', // Display mode dropdown toggle + 'showModelFilter', // Model/family filter dropdown toggle + 'setTimeRange' // Time range action + ]; + + const missing = filterElements.filter(el => !html.includes(el)); + if (missing.length > 0) { + throw new Error(`Missing filter elements: ${missing.join(', ')}`); + } + return 'All filter controls present'; + } + }, + { + name: 'Dashboard has chart and visualization elements', + async run() { + const res = await request('/views/dashboard.html'); + const html = res.data; + + // Dashboard now uses charts instead of tables + const visualElements = [ + 'quotaChart', // Quota distribution pie chart + 'usageTrendChart', // Usage trend line chart + 'usageStats.total', // Total usage stat + 'selectedFamilies' // Family selection for chart + ]; + + const missing = visualElements.filter(col => !html.includes(col)); + if (missing.length > 0) { + throw new Error(`Missing visualization elements: ${missing.join(', ')}`); + } + return 'All chart and visualization elements present'; + } + } +]; + +// Run tests +async function runTests() { + console.log('🧪 Dashboard Frontend Tests\n'); + console.log('='.repeat(50)); + + let passed = 0; + let failed = 0; + + for (const test of tests) { + try { + const result = await test.run(); + console.log(`✅ ${test.name}`); + console.log(` ${result}\n`); + passed++; + } catch (error) { + console.log(`❌ ${test.name}`); + console.log(` Error: ${error.message}\n`); + failed++; + } + } + + console.log('='.repeat(50)); + console.log(`Results: ${passed} passed, ${failed} failed`); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch(err => { + console.error('Test runner failed:', err); + process.exit(1); +}); diff --git a/tests/frontend/test-frontend-logs.cjs b/tests/frontend/test-frontend-logs.cjs new file mode 100755 index 0000000000000000000000000000000000000000..33e99222c639869e74c6249e4c377cbd6d5e4421 --- /dev/null +++ b/tests/frontend/test-frontend-logs.cjs @@ -0,0 +1,163 @@ +/** + * Frontend Test Suite - Logs Page + * Tests the logs viewer component functionality + * + * Run: node tests/test-frontend-logs.cjs + */ + +const http = require('http'); + +const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +function request(path, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(path, BASE_URL); + const req = http.request(url, { + method: options.method || 'GET', + headers: options.headers || {} + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + resolve({ status: res.statusCode, data, headers: res.headers }); + }); + }); + req.on('error', reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +const tests = [ + { + name: 'Logs view loads successfully', + async run() { + const res = await request('/views/logs.html'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + if (!res.data.includes('x-data="logsViewer"')) { + throw new Error('LogsViewer component not found'); + } + return 'Logs HTML loads with component'; + } + }, + { + name: 'Logs API endpoint exists', + async run() { + const res = await request('/api/logs'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.logs || !Array.isArray(data.logs)) { + throw new Error('logs array not found in response'); + } + return `API returns ${data.logs.length} log entries`; + } + }, + { + name: 'Logs SSE stream endpoint exists', + async run() { + return new Promise((resolve, reject) => { + const url = new URL('/api/logs/stream', BASE_URL); + const req = http.request(url, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`Expected 200, got ${res.statusCode}`)); + return; + } + if (res.headers['content-type'] !== 'text/event-stream') { + reject(new Error(`Expected text/event-stream, got ${res.headers['content-type']}`)); + return; + } + req.destroy(); // Close connection + resolve('SSE stream endpoint responds correctly'); + }); + req.on('error', reject); + req.end(); + }); + } + }, + { + name: 'Logs view has auto-scroll toggle', + async run() { + const res = await request('/views/logs.html'); + if (!res.data.includes('isAutoScroll')) { + throw new Error('Auto-scroll toggle not found'); + } + if (!res.data.includes('autoScroll')) { + throw new Error('Auto-scroll translation key not found'); + } + return 'Auto-scroll toggle present'; + } + }, + { + name: 'Logs view has clear logs button', + async run() { + const res = await request('/views/logs.html'); + if (!res.data.includes('clearLogs')) { + throw new Error('Clear logs function not found'); + } + return 'Clear logs button present'; + } + }, + { + name: 'Logs view has log container', + async run() { + const res = await request('/views/logs.html'); + if (!res.data.includes('logs-container')) { + throw new Error('Logs container element not found'); + } + if (!res.data.includes('x-for="(log, idx) in filteredLogs"')) { + throw new Error('Log iteration template not found'); + } + return 'Log container and template present'; + } + }, + { + name: 'Logs view shows log levels with colors', + async run() { + const res = await request('/views/logs.html'); + const levels = ['INFO', 'WARN', 'ERROR', 'SUCCESS', 'DEBUG']; + const colors = ['blue-400', 'yellow-400', 'red-500', 'neon-green', 'purple-400']; + + for (const level of levels) { + if (!res.data.includes(`'${level}'`)) { + throw new Error(`Log level ${level} styling not found`); + } + } + return 'All log levels have color styling'; + } + } +]; + +async function runTests() { + console.log('🧪 Logs Frontend Tests\n'); + console.log('='.repeat(50)); + + let passed = 0; + let failed = 0; + + for (const test of tests) { + try { + const result = await test.run(); + console.log(`✅ ${test.name}`); + console.log(` ${result}\n`); + passed++; + } catch (error) { + console.log(`❌ ${test.name}`); + console.log(` Error: ${error.message}\n`); + failed++; + } + } + + console.log('='.repeat(50)); + console.log(`Results: ${passed} passed, ${failed} failed`); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch(err => { + console.error('Test runner failed:', err); + process.exit(1); +}); diff --git a/tests/frontend/test-frontend-settings.cjs b/tests/frontend/test-frontend-settings.cjs new file mode 100755 index 0000000000000000000000000000000000000000..7cbc61632f55464679d9b76900076ddd91cd15ee --- /dev/null +++ b/tests/frontend/test-frontend-settings.cjs @@ -0,0 +1,445 @@ +/** + * Frontend Test Suite - Settings Page + * Tests the settings and Claude configuration components + * + * Run: node tests/test-frontend-settings.cjs + */ + +const http = require('http'); + +const BASE_URL = process.env.TEST_BASE_URL || `http://localhost:${process.env.PORT || 8080}`; + +function request(path, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(path, BASE_URL); + const req = http.request(url, { + method: options.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + resolve({ status: res.statusCode, data, headers: res.headers }); + }); + }); + req.on('error', reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +const tests = [ + // ==================== VIEW TESTS ==================== + { + name: 'Settings view loads successfully', + async run() { + const res = await request('/views/settings.html'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + return 'Settings HTML loads successfully'; + } + }, + { + name: 'Settings view has UI preferences section', + async run() { + const res = await request('/views/settings.html'); + const html = res.data; + + const uiElements = [ + 'language', // Language selector + 'refreshInterval', // Polling interval + 'logLimit', // Log buffer size + 'showExhausted', // Show exhausted models toggle + 'compact' // Compact mode toggle + ]; + + const missing = uiElements.filter(el => !html.includes(el)); + if (missing.length > 0) { + throw new Error(`Missing UI elements: ${missing.join(', ')}`); + } + return 'All UI preference elements present'; + } + }, + { + name: 'Settings view has Claude CLI config section', + async run() { + const res = await request('/views/settings.html'); + const html = res.data; + + if (!html.includes('x-data="claudeConfig"')) { + throw new Error('ClaudeConfig component not found'); + } + + const claudeElements = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_AUTH_TOKEN' + ]; + + const missing = claudeElements.filter(el => !html.includes(el)); + if (missing.length > 0) { + throw new Error(`Missing Claude config elements: ${missing.join(', ')}`); + } + return 'Claude CLI config section present'; + } + }, + { + name: 'Settings view has save buttons', + async run() { + const res = await request('/views/settings.html'); + const html = res.data; + + if (!html.includes('saveSettings')) { + throw new Error('Settings save function not found'); + } + if (!html.includes('saveClaudeConfig')) { + throw new Error('Claude config save function not found'); + } + return 'Save buttons present for both sections'; + } + }, + + // ==================== API TESTS ==================== + { + name: 'Server config API GET works', + async run() { + const res = await request('/api/config'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.config) { + throw new Error('config object not found in response'); + } + return `Config API returns: debug=${data.config.debug}, logLevel=${data.config.logLevel}`; + } + }, + { + name: 'Claude config API GET works', + async run() { + const res = await request('/api/claude/config'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.config) { + throw new Error('config object not found in response'); + } + if (!data.path) { + throw new Error('config path not found in response'); + } + return `Claude config loaded from: ${data.path}`; + } + }, + { + name: 'Claude config has env section', + async run() { + const res = await request('/api/claude/config'); + const data = JSON.parse(res.data); + + if (!data.config.env) { + throw new Error('env section not found in config'); + } + + const envKeys = Object.keys(data.config.env); + return `Config has ${envKeys.length} env vars: ${envKeys.slice(0, 3).join(', ')}${envKeys.length > 3 ? '...' : ''}`; + } + }, + { + name: 'Claude config API POST works (read-back test)', + async run() { + // First, read current config + const getRes = await request('/api/claude/config'); + const originalConfig = JSON.parse(getRes.data).config; + + // POST the same config back (safe operation) + const postRes = await request('/api/claude/config', { + method: 'POST', + body: originalConfig + }); + + if (postRes.status !== 200) { + throw new Error(`POST failed with status ${postRes.status}`); + } + + const postData = JSON.parse(postRes.data); + if (postData.status !== 'ok') { + throw new Error(`POST returned error: ${postData.error}`); + } + + return 'Claude config POST API works (config preserved)'; + } + }, + { + name: 'Server config API POST validates input', + async run() { + // Test with invalid logLevel + const res = await request('/api/config', { + method: 'POST', + body: { logLevel: 'invalid_level' } + }); + + if (res.status === 200) { + const data = JSON.parse(res.data); + // Check if the invalid value was rejected + if (data.updates && data.updates.logLevel === 'invalid_level') { + throw new Error('Invalid logLevel was accepted'); + } + } + + return 'Config API properly validates logLevel input'; + } + }, + { + name: 'Server config accepts valid debug value', + async run() { + // Get current config + const getRes = await request('/api/config'); + const currentDebug = JSON.parse(getRes.data).config.debug; + + // Toggle debug + const postRes = await request('/api/config', { + method: 'POST', + body: { debug: !currentDebug } + }); + + if (postRes.status !== 200) { + throw new Error(`POST failed with status ${postRes.status}`); + } + + // Restore original value + await request('/api/config', { + method: 'POST', + body: { debug: currentDebug } + }); + + return 'Config API accepts valid debug boolean'; + } + }, + + // ==================== SETTINGS STORE TESTS ==================== + { + name: 'Settings API returns server port', + async run() { + const res = await request('/api/settings'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (!data.settings || !data.settings.port) { + throw new Error('port not found in settings'); + } + return `Server port: ${data.settings.port}`; + } + }, + + // ==================== INTEGRATION TESTS ==================== + { + name: 'All views are accessible', + async run() { + const views = ['dashboard', 'logs', 'accounts', 'settings']; + const results = []; + + for (const view of views) { + const res = await request(`/views/${view}.html`); + if (res.status !== 200) { + throw new Error(`${view} view returned ${res.status}`); + } + results.push(`${view}: OK`); + } + + return results.join(', '); + } + }, + { + name: 'All component JS files load', + async run() { + const components = [ + 'js/components/dashboard.js', + 'js/components/account-manager.js', + 'js/components/claude-config.js', + 'js/components/logs-viewer.js' + ]; + + for (const comp of components) { + const res = await request(`/${comp}`); + if (res.status !== 200) { + throw new Error(`${comp} returned ${res.status}`); + } + if (!res.data.includes('window.Components')) { + throw new Error(`${comp} doesn't register to window.Components`); + } + } + + return 'All component files load and register correctly'; + } + }, + { + name: 'All store JS files load', + async run() { + const stores = [ + 'js/store.js', + 'js/data-store.js', + 'js/settings-store.js', + 'js/utils.js' + ]; + + for (const store of stores) { + const res = await request(`/${store}`); + if (res.status !== 200) { + throw new Error(`${store} returned ${res.status}`); + } + } + + return 'All store files load correctly'; + } + }, + { + name: 'Main app.js loads', + async run() { + const res = await request('/app.js'); + if (res.status !== 200) { + throw new Error(`app.js returned ${res.status}`); + } + if (!res.data.includes('alpine:init')) { + throw new Error('app.js missing alpine:init listener'); + } + if (!res.data.includes('load-view')) { + throw new Error('app.js missing load-view directive'); + } + return 'app.js loads with all required components'; + } + }, + + // ==================== PRESETS API TESTS ==================== + { + name: 'Presets API GET returns presets array', + async run() { + const res = await request('/api/claude/presets'); + if (res.status !== 200) { + throw new Error(`Expected 200, got ${res.status}`); + } + const data = JSON.parse(res.data); + if (data.status !== 'ok') { + throw new Error(`Expected status ok, got ${data.status}`); + } + if (!Array.isArray(data.presets)) { + throw new Error('presets should be an array'); + } + return `Presets API returns ${data.presets.length} preset(s)`; + } + }, + { + name: 'Presets API POST creates new preset', + async run() { + const testPreset = { + name: '__test_preset__', + config: { + ANTHROPIC_BASE_URL: 'http://localhost:8080', + ANTHROPIC_MODEL: 'test-model' + } + }; + + // Create preset + const postRes = await request('/api/claude/presets', { + method: 'POST', + body: testPreset + }); + if (postRes.status !== 200) { + throw new Error(`POST failed with status ${postRes.status}`); + } + const postData = JSON.parse(postRes.data); + if (postData.status !== 'ok') { + throw new Error(`POST returned error: ${postData.error}`); + } + + // Verify it exists + const getRes = await request('/api/claude/presets'); + const getData = JSON.parse(getRes.data); + const found = getData.presets.find(p => p.name === '__test_preset__'); + if (!found) { + throw new Error('Created preset not found in list'); + } + + return 'Preset created and verified'; + } + }, + { + name: 'Presets API DELETE removes preset', + async run() { + // Delete the test preset created above + const deleteRes = await request('/api/claude/presets/__test_preset__', { + method: 'DELETE' + }); + if (deleteRes.status !== 200) { + throw new Error(`DELETE failed with status ${deleteRes.status}`); + } + + // Verify it's gone + const getRes = await request('/api/claude/presets'); + const getData = JSON.parse(getRes.data); + const found = getData.presets.find(p => p.name === '__test_preset__'); + if (found) { + throw new Error('Deleted preset still exists'); + } + + return 'Preset deleted and verified'; + } + }, + { + name: 'Settings view has presets UI elements', + async run() { + const res = await request('/views/settings.html'); + const html = res.data; + + const presetElements = [ + 'selectedPresetName', // Preset dropdown binding + 'saveCurrentAsPreset', // Save button function + 'deleteSelectedPreset', // Delete button function + 'save_preset_modal', // Save modal + 'configPresets' // Translation key for section title + ]; + + const missing = presetElements.filter(el => !html.includes(el)); + if (missing.length > 0) { + throw new Error(`Missing preset UI elements: ${missing.join(', ')}`); + } + return 'All preset UI elements present'; + } + } +]; + +async function runTests() { + console.log('🧪 Settings Frontend Tests\n'); + console.log('='.repeat(50)); + + let passed = 0; + let failed = 0; + + for (const test of tests) { + try { + const result = await test.run(); + console.log(`✅ ${test.name}`); + console.log(` ${result}\n`); + passed++; + } catch (error) { + console.log(`❌ ${test.name}`); + console.log(` Error: ${error.message}\n`); + failed++; + } + } + + console.log('='.repeat(50)); + console.log(`Results: ${passed} passed, ${failed} failed`); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests().catch(err => { + console.error('Test runner failed:', err); + process.exit(1); +}); diff --git a/tests/helpers/http-client.cjs b/tests/helpers/http-client.cjs new file mode 100755 index 0000000000000000000000000000000000000000..bdbbc79a2ea0e8bec9c6b746c6820d9ca5cd8a6c --- /dev/null +++ b/tests/helpers/http-client.cjs @@ -0,0 +1,306 @@ +/** + * Shared Test HTTP Client Utilities + * + * Provides common HTTP request functions for integration tests. + * Eliminates code duplication across test files. + */ +const http = require('http'); + +// Server configuration +const BASE_URL = 'localhost'; +const PORT = 8080; + +/** + * Make a streaming SSE request to the API + * @param {Object} body - Request body + * @returns {Promise<{content: Array, events: Array, statusCode: number, raw: string}>} + */ +function streamRequest(body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + host: BASE_URL, + port: PORT, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + 'Content-Length': Buffer.byteLength(data) + } + }, res => { + const events = []; + let fullData = ''; + + res.on('data', chunk => { + fullData += chunk.toString(); + }); + + res.on('end', () => { + // Parse SSE events + const parts = fullData.split('\n\n').filter(e => e.trim()); + for (const part of parts) { + const lines = part.split('\n'); + const eventLine = lines.find(l => l.startsWith('event:')); + const dataLine = lines.find(l => l.startsWith('data:')); + if (eventLine && dataLine) { + try { + const eventType = eventLine.replace('event:', '').trim(); + const eventData = JSON.parse(dataLine.replace('data:', '').trim()); + events.push({ type: eventType, data: eventData }); + } catch (e) { } + } + } + + // Build content from events + const content = []; + let currentBlock = null; + + for (const event of events) { + if (event.type === 'content_block_start') { + currentBlock = { ...event.data.content_block }; + if (currentBlock.type === 'thinking') { + currentBlock.thinking = ''; + currentBlock.signature = ''; + } + if (currentBlock.type === 'text') currentBlock.text = ''; + } else if (event.type === 'content_block_delta') { + const delta = event.data.delta; + if (delta.type === 'thinking_delta' && currentBlock) { + currentBlock.thinking += delta.thinking || ''; + } + if (delta.type === 'signature_delta' && currentBlock) { + currentBlock.signature += delta.signature || ''; + } + if (delta.type === 'text_delta' && currentBlock) { + currentBlock.text += delta.text || ''; + } + if (delta.type === 'input_json_delta' && currentBlock) { + currentBlock.partial_json = (currentBlock.partial_json || '') + delta.partial_json; + } + } else if (event.type === 'content_block_stop') { + if (currentBlock?.type === 'tool_use' && currentBlock.partial_json) { + try { currentBlock.input = JSON.parse(currentBlock.partial_json); } catch (e) { } + delete currentBlock.partial_json; + } + if (currentBlock) content.push(currentBlock); + currentBlock = null; + } + } + + resolve({ content, events, statusCode: res.statusCode, raw: fullData }); + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +/** + * Make a non-streaming JSON request to the API + * @param {Object} body - Request body + * @returns {Promise} - Parsed JSON response with statusCode + */ +function makeRequest(body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = http.request({ + host: BASE_URL, + port: PORT, + path: '/v1/messages', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01', + 'anthropic-beta': 'interleaved-thinking-2025-05-14', + 'Content-Length': Buffer.byteLength(data) + } + }, res => { + let fullData = ''; + res.on('data', chunk => fullData += chunk.toString()); + res.on('end', () => { + try { + const parsed = JSON.parse(fullData); + resolve({ ...parsed, statusCode: res.statusCode }); + } catch (e) { + reject(new Error(`Parse error: ${e.message}\nRaw: ${fullData.substring(0, 500)}`)); + } + }); + }); + req.on('error', reject); + req.write(data); + req.end(); + }); +} + +/** + * Analyze content blocks from a response + * @param {Array} content - Array of content blocks + * @returns {Object} - Analysis results + */ +function analyzeContent(content) { + const thinking = content.filter(b => b.type === 'thinking'); + const toolUse = content.filter(b => b.type === 'tool_use'); + const text = content.filter(b => b.type === 'text'); + + // Check for signatures in thinking blocks (Claude style) + const thinkingHasSignature = thinking.some(t => t.signature && t.signature.length >= 50); + + // Check for signatures in tool_use blocks (Gemini 3+ style) + const toolUseHasSignature = toolUse.some(t => t.thoughtSignature && t.thoughtSignature.length >= 50); + + return { + thinking, + toolUse, + text, + hasThinking: thinking.length > 0, + hasToolUse: toolUse.length > 0, + hasText: text.length > 0, + thinkingHasSignature: thinkingHasSignature, + toolUseHasSignature: toolUseHasSignature, + // Combined check: signature exists somewhere (thinking or tool_use) + hasSignature: thinkingHasSignature || toolUseHasSignature + }; +} + +/** + * Analyze SSE events from a streaming response + * @param {Array} events - Array of SSE events + * @returns {Object} - Event counts by type + */ +function analyzeEvents(events) { + return { + messageStart: events.filter(e => e.type === 'message_start').length, + blockStart: events.filter(e => e.type === 'content_block_start').length, + blockDelta: events.filter(e => e.type === 'content_block_delta').length, + blockStop: events.filter(e => e.type === 'content_block_stop').length, + messageDelta: events.filter(e => e.type === 'message_delta').length, + messageStop: events.filter(e => e.type === 'message_stop').length, + thinkingDeltas: events.filter(e => e.data?.delta?.type === 'thinking_delta').length, + signatureDeltas: events.filter(e => e.data?.delta?.type === 'signature_delta').length, + textDeltas: events.filter(e => e.data?.delta?.type === 'text_delta').length, + inputJsonDeltas: events.filter(e => e.data?.delta?.type === 'input_json_delta').length + }; +} + +/** + * Extract usage metadata from SSE events + * @param {Array} events - Array of SSE events + * @returns {Object} - Usage info with input/output/cache tokens + */ +function extractUsage(events) { + const usage = { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0 + }; + + // Get usage from message_start + const messageStart = events.find(e => e.type === 'message_start'); + if (messageStart?.data?.message?.usage) { + const startUsage = messageStart.data.message.usage; + usage.input_tokens = startUsage.input_tokens || 0; + usage.cache_read_input_tokens = startUsage.cache_read_input_tokens || 0; + usage.cache_creation_input_tokens = startUsage.cache_creation_input_tokens || 0; + } + + // Get output tokens from message_delta + const messageDelta = events.find(e => e.type === 'message_delta'); + if (messageDelta?.data?.usage) { + const deltaUsage = messageDelta.data.usage; + usage.output_tokens = deltaUsage.output_tokens || 0; + // Also check for cache tokens in delta (may be updated) + if (deltaUsage.cache_read_input_tokens !== undefined) { + usage.cache_read_input_tokens = deltaUsage.cache_read_input_tokens; + } + } + + return usage; +} + +// Common tool definitions for tests +const commonTools = { + getWeather: { + name: 'get_weather', + description: 'Get the current weather for a location', + input_schema: { + type: 'object', + properties: { + location: { type: 'string', description: 'City name' } + }, + required: ['location'] + } + }, + searchFiles: { + name: 'search_files', + description: 'Search for files matching a pattern', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Glob pattern to search' }, + path: { type: 'string', description: 'Directory to search in' } + }, + required: ['pattern'] + } + }, + readFile: { + name: 'read_file', + description: 'Read contents of a file', + input_schema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Path to file' } + }, + required: ['path'] + } + }, + executeCommand: { + name: 'execute_command', + description: 'Execute a shell command', + input_schema: { + type: 'object', + properties: { + command: { type: 'string', description: 'Command to execute' }, + cwd: { type: 'string', description: 'Working directory' } + }, + required: ['command'] + } + }, + writeFile: { + name: 'write_file', + description: 'Write to a file', + input_schema: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' } + }, + required: ['path', 'content'] + } + }, + runTests: { + name: 'run_tests', + description: 'Run test suite', + input_schema: { + type: 'object', + properties: { pattern: { type: 'string' } }, + required: ['pattern'] + } + } +}; + +module.exports = { + BASE_URL, + PORT, + streamRequest, + makeRequest, + analyzeContent, + analyzeEvents, + extractUsage, + commonTools +}; diff --git a/tests/helpers/test-models.cjs b/tests/helpers/test-models.cjs new file mode 100755 index 0000000000000000000000000000000000000000..602eff944b91e2e55b6cfb05d2b957416fc31fc9 --- /dev/null +++ b/tests/helpers/test-models.cjs @@ -0,0 +1,80 @@ +/** + * Test Models Configuration + * + * Provides model configuration for parameterized testing across + * multiple model families (Claude and Gemini). + * + * TEST_MODELS is imported from src/constants.js (single source of truth). + */ + +let TEST_MODELS; + +// Dynamic import to bridge ESM -> CJS +async function loadConstants() { + if (!TEST_MODELS) { + const constants = await import('../../src/constants.js'); + TEST_MODELS = constants.TEST_MODELS; + } + return TEST_MODELS; +} + +/** + * Get models to test, optionally excluding certain families. + * @param {string[]} excludeFamilies - Array of family names to exclude (e.g., ['gemini']) + * @returns {Promise>} Array of model configs to test + */ +async function getTestModels(excludeFamilies = []) { + const testModels = await loadConstants(); + const models = []; + for (const [family, model] of Object.entries(testModels)) { + if (!excludeFamilies.includes(family)) { + models.push({ family, model }); + } + } + return models; +} + +/** + * Check if a model family requires thinking features. + * Both Claude thinking models and Gemini 3+ support thinking. + * @param {string} family - Model family name + * @returns {boolean} True if thinking is expected + */ +function familySupportsThinking(family) { + // Both Claude thinking models and Gemini 3+ support thinking + return family === 'claude' || family === 'gemini'; +} + +/** + * Get model-specific configuration overrides. + * @param {string} family - Model family name + * @returns {Object} Configuration overrides for the model family + */ +function getModelConfig(family) { + if (family === 'gemini') { + return { + // Gemini has lower max output tokens + max_tokens: 8000, + thinking: { type: 'enabled', budget_tokens: 10000 } + }; + } + return { + max_tokens: 16000, + thinking: { type: 'enabled', budget_tokens: 10000 } + }; +} + +/** + * Get TEST_MODELS directly (async). + * @returns {Promise} TEST_MODELS object + */ +async function getModels() { + return loadConstants(); +} + +module.exports = { + getTestModels, + getModels, + familySupportsThinking, + getModelConfig +}; diff --git a/tests/migration/config-env.test.js b/tests/migration/config-env.test.js new file mode 100755 index 0000000000000000000000000000000000000000..9224575b610d11821b40bc3e97ce3331e4726d40 --- /dev/null +++ b/tests/migration/config-env.test.js @@ -0,0 +1,26 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { applyConfigEnv, parseConfigEnvBlock } from '../../src/bootstrap/load-config-env.js'; + +test('CONFIG_ENV parser accepts comments, export, and quoted values', () => { + const parsed = parseConfigEnvBlock(` +# comment +export PROXY_API_KEY="placeholder-key" +ANTIGRAVITY_1_EMAIL='one@example.invalid' +ANTIGRAVITY_1_REFRESH_TOKEN=YOUR_REFRESH_TOKEN_HERE +`); + + assert.equal(parsed.PROXY_API_KEY, 'placeholder-key'); + assert.equal(parsed.ANTIGRAVITY_1_EMAIL, 'one@example.invalid'); + assert.equal(parsed.ANTIGRAVITY_1_REFRESH_TOKEN, 'YOUR_REFRESH_TOKEN_HERE'); +}); + +test('explicit environment variables win over CONFIG_ENV', () => { + const target = { PROXY_API_KEY: 'explicit-key' }; + applyConfigEnv('PROXY_API_KEY=bulk-key\nPORT=7860', target); + assert.equal(target.PROXY_API_KEY, 'explicit-key'); + assert.equal(target.PORT, '7860'); +}); + diff --git a/tests/migration/env-accounts.test.js b/tests/migration/env-accounts.test.js new file mode 100755 index 0000000000000000000000000000000000000000..93085cfb5ff14b3dffec36ae3ac2b6352ee6bc5a --- /dev/null +++ b/tests/migration/env-accounts.test.js @@ -0,0 +1,53 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { bootstrapEnvAccounts, collectEnvAccounts } from '../../src/bootstrap/env-accounts.js'; + +test('numbered Antigravity environment credentials become OAuth accounts', () => { + const accounts = collectEnvAccounts({ + ANTIGRAVITY_1_EMAIL: 'one@example.invalid', + ANTIGRAVITY_1_REFRESH_TOKEN: 'YOUR_REFRESH_TOKEN_1_HERE', + ANTIGRAVITY_1_PROJECT_ID: 'project-one', + ANTIGRAVITY_1_TIER: 'pro', + ANTIGRAVITY_2_EMAIL: 'two@example.invalid', + ANTIGRAVITY_2_REFRESH_TOKEN: 'YOUR_REFRESH_TOKEN_2_HERE' + }); + + assert.equal(accounts.length, 2); + assert.equal(accounts[0].source, 'oauth'); + assert.equal(accounts[0].subscription.tier, 'pro'); + assert.equal(accounts[0].refreshToken, 'YOUR_REFRESH_TOKEN_1_HERE|project-one|project-one'); +}); + +test('an email is required for every refresh token', () => { + assert.throws(() => collectEnvAccounts({ + ANTIGRAVITY_1_REFRESH_TOKEN: 'YOUR_REFRESH_TOKEN_HERE' + }), /ANTIGRAVITY_1_EMAIL is required/); +}); + +test('bootstrap persists environment accounts in existing storage format', async () => { + const directory = await mkdtemp(join(tmpdir(), 'antigravity-env-accounts-')); + const configPath = join(directory, 'accounts.json'); + + try { + const result = await bootstrapEnvAccounts(configPath, { + FORCE_ENV_ACCOUNTS: 'true', + ANTIGRAVITY_1_EMAIL: 'one@example.invalid', + ANTIGRAVITY_1_REFRESH_TOKEN: 'YOUR_REFRESH_TOKEN_1_HERE', + ANTIGRAVITY_1_PROJECT_ID: 'project-one' + }); + const saved = JSON.parse(await readFile(configPath, 'utf8')); + + assert.equal(result.count, 1); + assert.equal(saved.accounts.length, 1); + assert.equal(saved.accounts[0].email, 'one@example.invalid'); + assert.equal(saved.accounts[0].source, 'oauth'); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + diff --git a/tests/migration/openai-compat.test.js b/tests/migration/openai-compat.test.js new file mode 100755 index 0000000000000000000000000000000000000000..be3207a309dd3867623f82c676cb780811cd2eab --- /dev/null +++ b/tests/migration/openai-compat.test.js @@ -0,0 +1,34 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + convertAnthropicResponseToOpenAI, + convertOpenAIRequestToAnthropic +} from '../../src/format/openai-compat.js'; + +test('legacy Flash alias resolves and preserves reasoning_effort', () => { + const converted = convertOpenAIRequestToAnthropic({ + model: 'antigravity/gemini-3-flash', + messages: [{ role: 'user', content: 'hello' }], + reasoning_effort: 'high' + }); + + assert.equal(converted.model, 'gemini-3.5-flash-low'); + assert.equal(converted.reasoning_effort, 'high'); + assert.equal(converted.messages[0].role, 'user'); +}); + +test('non-streaming response becomes OpenAI chat.completion', () => { + const converted = convertAnthropicResponseToOpenAI({ + id: 'msg_123', + content: [{ type: 'text', text: 'hello' }], + stop_reason: 'end_turn', + usage: { input_tokens: 3, output_tokens: 2 } + }, 'antigravity/gemini-3-flash'); + + assert.equal(converted.object, 'chat.completion'); + assert.equal(converted.choices[0].message.content, 'hello'); + assert.equal(converted.usage.total_tokens, 5); +}); + diff --git a/tests/migration/openai-stream.test.js b/tests/migration/openai-stream.test.js new file mode 100755 index 0000000000000000000000000000000000000000..153675fd4db73c38fadbe6275fefe5cd766e7b60 --- /dev/null +++ b/tests/migration/openai-stream.test.js @@ -0,0 +1,58 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + convertAnthropicEventToOpenAIChunks, + createOpenAIStreamState +} from '../../src/format/openai-stream.js'; + +test('Anthropic events become OpenAI chunks and a final usage chunk', () => { + const state = createOpenAIStreamState('gemini-3.5-flash-low', true); + const events = [ + { + type: 'message_start', + message: { id: 'msg_abc', usage: { input_tokens: 4 } } + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hi' } + }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 1 } + }, + { type: 'message_stop' } + ]; + + const chunks = events.flatMap(event => convertAnthropicEventToOpenAIChunks(event, state)); + assert.equal(chunks[0].choices[0].delta.role, 'assistant'); + assert.equal(chunks[1].choices[0].delta.content, 'Hi'); + assert.equal(chunks[2].choices[0].finish_reason, 'stop'); + assert.equal(chunks[3].usage.total_tokens, 5); +}); + +test('tool deltas keep a stable OpenAI tool call index', () => { + const state = createOpenAIStreamState('gemini-3.5-flash-low', false); + convertAnthropicEventToOpenAIChunks({ + type: 'message_start', + message: { id: 'msg_tool', usage: { input_tokens: 2 } } + }, state); + + const start = convertAnthropicEventToOpenAIChunks({ + type: 'content_block_start', + index: 3, + content_block: { type: 'tool_use', id: 'call_1', name: 'lookup' } + }, state); + const delta = convertAnthropicEventToOpenAIChunks({ + type: 'content_block_delta', + index: 3, + delta: { type: 'input_json_delta', partial_json: '{"id":"1"}' } + }, state); + + assert.equal(start[0].choices[0].delta.tool_calls[0].index, 0); + assert.equal(delta[0].choices[0].delta.tool_calls[0].index, 0); +}); + diff --git a/tests/migration/openai-tools.test.js b/tests/migration/openai-tools.test.js new file mode 100755 index 0000000000000000000000000000000000000000..482a45be83546ccb6f1f13b7d0603006771c97ea --- /dev/null +++ b/tests/migration/openai-tools.test.js @@ -0,0 +1,55 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { convertOpenAIRequestToAnthropic } from '../../src/format/openai-compat.js'; + +test('assistant tool_calls and tool results convert to Anthropic blocks', () => { + const converted = convertOpenAIRequestToAnthropic({ + model: 'gemini-3.5-flash-low', + messages: [ + { role: 'user', content: 'weather?' }, + { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_weather', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Delhi"}' } + }] + }, + { + role: 'tool', + tool_call_id: 'call_weather', + content: '{"temperature":32}' + } + ], + tools: [{ + type: 'function', + function: { + name: 'get_weather', + description: 'Get weather', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'] + } + } + }] + }); + + assert.equal(converted.messages[1].content[0].type, 'tool_use'); + assert.deepEqual(converted.messages[1].content[0].input, { city: 'Delhi' }); + assert.equal(converted.messages[2].content[0].name, 'get_weather'); +}); + +test('OpenAI web_search tool becomes built-in googleSearch', () => { + const converted = convertOpenAIRequestToAnthropic({ + model: 'gemini-3.5-flash-low', + messages: [{ role: 'user', content: 'Find current information.' }], + tools: [{ type: 'web_search' }] + }); + + assert.deepEqual(converted.tools, [{ googleSearch: {} }]); +}); + diff --git a/tests/migration/pro-high-routing.test.js b/tests/migration/pro-high-routing.test.js new file mode 100755 index 0000000000000000000000000000000000000000..cb91f0c5da4048c65a090c3dc3011fac302d7643 --- /dev/null +++ b/tests/migration/pro-high-routing.test.js @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyProHighThinkingFallback, + resolveCloudCodeApiModel +} from '../../src/format/model-aliases.js'; +import { getReasoningThinkingConfig } from '../../src/format/reasoning-effort.js'; + +test('resolveCloudCodeApiModel routes pro-high SKU to pro-low for upstream API', () => { + assert.equal(resolveCloudCodeApiModel('gemini-3.1-pro-high'), 'gemini-3.1-pro-low'); + assert.equal(resolveCloudCodeApiModel('gemini-3.1-pro-low'), 'gemini-3.1-pro-low'); +}); + +test('applyProHighThinkingFallback injects high thinking level', () => { + const googleRequest = { generationConfig: { maxOutputTokens: 256 } }; + applyProHighThinkingFallback(googleRequest, 'gemini-3.1-pro-high'); + assert.deepEqual(googleRequest.generationConfig.thinkingConfig, { + includeThoughts: true, + thinkingLevel: 'high' + }); +}); + +test('tier SKUs skip duplicate reasoning_effort thinkingConfig', () => { + assert.equal(getReasoningThinkingConfig('gemini-3.1-pro-high', 'high'), null); + assert.equal(getReasoningThinkingConfig('gemini-3.1-pro-low', 'high'), null); +}); diff --git a/tests/migration/public-sanitize.test.js b/tests/migration/public-sanitize.test.js new file mode 100755 index 0000000000000000000000000000000000000000..64bb68bf4963faa4e6cbfa56858ee02cfc57bdd6 --- /dev/null +++ b/tests/migration/public-sanitize.test.js @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + maskEmail, + sanitizeHealthPayload, + sanitizeAccountLimitsPayload +} from '../../src/utils/public-sanitize.js'; + +test('maskEmail hides most of the local part', () => { + assert.equal(maskEmail('bardbardd92@gmail.com'), 'ba******92@gmail.com'); + assert.equal(maskEmail('ab@example.com'), 'a***@example.com'); +}); + +test('sanitizeHealthPayload removes raw emails and project metadata', () => { + const sanitized = sanitizeHealthPayload({ + status: 'ok', + counts: { total: 1 }, + accounts: [{ + email: 'secret@example.com', + status: 'ok', + lastUsed: '2026-06-14T00:00:00.000Z', + subscription: { tier: 'pro', projectId: 'my-project' }, + models: { + 'gemini-3.5-flash-low': { + remaining: '100%', + remainingFraction: 1, + resetTime: '2026-06-14T20:00:00Z' + } + } + }] + }); + + assert.equal(sanitized.accounts[0].email, 'se******et@example.com'); + assert.equal(sanitized.accounts[0].id, 'account-1'); + assert.equal(sanitized.accounts[0].subscription, undefined); + assert.equal(sanitized.accounts[0].lastUsed, undefined); + assert.equal(sanitized.accounts[0].models['gemini-3.5-flash-low'].remaining, '100%'); +}); + +test('sanitizeAccountLimitsPayload strips project IDs', () => { + const sanitized = sanitizeAccountLimitsPayload({ + totalAccounts: 1, + models: ['gemini-3.5-flash-low'], + modelConfig: { secret: true }, + accounts: [{ + email: 'secret@example.com', + status: 'ok', + projectId: 'my-project', + subscription: { tier: 'pro', projectId: 'my-project' }, + limits: { + 'gemini-3.5-flash-low': { remainingFraction: 1 } + } + }] + }); + + assert.equal(sanitized.modelConfig.secret, undefined); + assert.equal(sanitized.accounts[0].subscription.projectId, undefined); + assert.equal(sanitized.accounts[0].subscription.tier, 'pro'); + assert.equal(sanitized.accounts[0].projectId, undefined); +}); diff --git a/tests/migration/reasoning-effort.test.js b/tests/migration/reasoning-effort.test.js new file mode 100755 index 0000000000000000000000000000000000000000..57520734d38d58c02125251a4276552da59c1cc6 --- /dev/null +++ b/tests/migration/reasoning-effort.test.js @@ -0,0 +1,32 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { getReasoningThinkingConfig } from '../../src/format/reasoning-effort.js'; + +test('Gemini 3.5 Flash high uses thinkingLevel, not a fake model', () => { + const result = getReasoningThinkingConfig('gemini-3.5-flash-low', 'high'); + assert.deepEqual(result.config, { + includeThoughts: true, + thinkingLevel: 'high' + }); +}); + +test('Gemini 3.5 Flash effort levels match old proxy', () => { + assert.equal(getReasoningThinkingConfig('gemini-3.5-flash-low', 'disable').config.thinkingLevel, 'minimal'); + assert.equal(getReasoningThinkingConfig('gemini-3.5-flash-low', 'low').config.thinkingLevel, 'low'); + assert.equal(getReasoningThinkingConfig('gemini-3.5-flash-low', 'medium').config.thinkingLevel, 'medium'); + assert.equal(getReasoningThinkingConfig('gemini-3.5-flash-low', 'auto').config.thinkingLevel, 'high'); +}); + +test('Gemini 3 Pro only maps to low or high', () => { + assert.equal(getReasoningThinkingConfig('gemini-3.1-pro-low', 'low_medium').config.thinkingLevel, 'low'); + assert.equal(getReasoningThinkingConfig('gemini-3.1-pro-high', 'medium').config.thinkingLevel, 'high'); +}); + +test('Gemini 2.5 and Claude use budgets', () => { + assert.equal(getReasoningThinkingConfig('gemini-2.5-flash', 'high').config.thinkingBudget, 24576); + assert.equal(getReasoningThinkingConfig('gemini-2.5-pro', 'high').config.thinkingBudget, 32768); + assert.equal(getReasoningThinkingConfig('claude-sonnet-4-5-thinking', 'high').config.thinking_budget, 31999); +}); + diff --git a/tests/migration/request-converter-thinking.test.js b/tests/migration/request-converter-thinking.test.js new file mode 100755 index 0000000000000000000000000000000000000000..74c67df0b9ec874f580db01b7907efa4b6dcc140 --- /dev/null +++ b/tests/migration/request-converter-thinking.test.js @@ -0,0 +1,31 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { convertAnthropicToGoogle } from '../../src/format/request-converter.js'; + +test('OpenAI Flash high reaches Google as thinkingLevel high', () => { + const google = convertAnthropicToGoogle({ + model: 'gemini-3.5-flash-low', + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 1024, + reasoning_effort: 'high' + }); + + assert.deepEqual(google.generationConfig.thinkingConfig, { + includeThoughts: true, + thinkingLevel: 'high' + }); + assert.equal('thinkingBudget' in google.generationConfig.thinkingConfig, false); +}); + +test('Gemini 2.5 uses thinkingBudget', () => { + const google = convertAnthropicToGoogle({ + model: 'gemini-2.5-flash', + messages: [{ role: 'user', content: 'hello' }], + reasoning_effort: 'medium' + }); + + assert.equal(google.generationConfig.thinkingConfig.thinkingBudget, 12288); +}); + diff --git a/tests/migration/request-converter-tools.test.js b/tests/migration/request-converter-tools.test.js new file mode 100755 index 0000000000000000000000000000000000000000..c66426d9a5e5906f5399b7503bb6bf1f329c7690 --- /dev/null +++ b/tests/migration/request-converter-tools.test.js @@ -0,0 +1,25 @@ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { convertAnthropicToGoogle } from '../../src/format/request-converter.js'; + +test('googleSearch is a built-in tool, not a function declaration', () => { + const google = convertAnthropicToGoogle({ + model: 'gemini-3.5-flash-low', + messages: [{ role: 'user', content: 'latest news' }], + tools: [ + { googleSearch: {} }, + { + name: 'lookup_record', + description: 'Look up a record', + input_schema: { type: 'object', properties: { id: { type: 'string' } } } + } + ] + }); + + assert.equal(google.tools.some(tool => tool.googleSearch), true); + const declarations = google.tools.find(tool => tool.functionDeclarations)?.functionDeclarations || []; + assert.deepEqual(declarations.map(item => item.name), ['lookup_record']); +}); + diff --git a/tests/run-all.cjs b/tests/run-all.cjs new file mode 100755 index 0000000000000000000000000000000000000000..c3c376384bfb4044d4329f64a4d07a6af803e236 --- /dev/null +++ b/tests/run-all.cjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Test Runner + * + * Runs all tests in sequence and reports results. + * Usage: node tests/run-all.cjs + */ +const { spawn } = require('child_process'); +const path = require('path'); + +const tests = [ + { name: 'Account Selection Strategies', file: 'test-strategies.cjs' }, + { name: 'Cache Control Stripping', file: 'test-cache-control.cjs' }, + { name: 'Thinking Signatures', file: 'test-thinking-signatures.cjs' }, + { name: 'Multi-turn Tools (Non-Streaming)', file: 'test-multiturn-thinking-tools.cjs' }, + { name: 'Multi-turn Tools (Streaming)', file: 'test-multiturn-thinking-tools-streaming.cjs' }, + { name: 'Interleaved Thinking', file: 'test-interleaved-thinking.cjs' }, + { name: 'Image Support', file: 'test-images.cjs' }, + { name: 'Prompt Caching', file: 'test-caching-streaming.cjs' }, + { name: 'Cross-Model Thinking', file: 'test-cross-model-thinking.cjs' }, + { name: 'OAuth No-Browser Mode', file: 'test-oauth-no-browser.cjs' }, + { name: 'Empty Response Retry', file: 'test-empty-response-retry.cjs' }, + { name: 'Schema Sanitizer', file: 'test-schema-sanitizer.cjs' }, + { name: 'Streaming Whitespace', file: 'test-streaming-whitespace.cjs' }, + { name: '403 Account Rotation (Unit)', file: 'test-403-account-rotation.cjs' }, + { name: '403 Account Rotation (Integration)', file: 'test-403-integration.cjs' }, + { name: 'Version Detection', file: 'test-version-detection.js' } +]; + +async function runTest(test) { + return new Promise((resolve) => { + const testPath = path.join(__dirname, test.file); + const child = spawn('node', [testPath], { + stdio: 'inherit' + }); + + child.on('close', (code) => { + resolve({ ...test, passed: code === 0 }); + }); + + child.on('error', (err) => { + console.error(`Error running ${test.name}:`, err); + resolve({ ...test, passed: false }); + }); + }); +} + +async function main() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ ANTIGRAVITY PROXY TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝'); + console.log(''); + console.log('Make sure the server is running on port 8080 before running tests.'); + console.log(''); + + // Check if running specific test + const specificTest = process.argv[2]; + let testsToRun = tests; + + if (specificTest) { + testsToRun = tests.filter(t => + t.file.includes(specificTest) || t.name.toLowerCase().includes(specificTest.toLowerCase()) + ); + if (testsToRun.length === 0) { + console.log(`No test found matching: ${specificTest}`); + console.log('\nAvailable tests:'); + tests.forEach(t => console.log(` - ${t.name} (${t.file})`)); + process.exit(1); + } + } + + const results = []; + + for (const test of testsToRun) { + console.log('\n'); + console.log('╔' + '═'.repeat(60) + '╗'); + console.log('║ Running: ' + test.name.padEnd(50) + '║'); + console.log('╚' + '═'.repeat(60) + '╝'); + console.log(''); + + const result = await runTest(test); + results.push(result); + + console.log('\n'); + } + + // Summary + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ FINAL RESULTS ║'); + console.log('╠══════════════════════════════════════════════════════════════╣'); + + let allPassed = true; + for (const result of results) { + const status = result.passed ? '✓ PASS' : '✗ FAIL'; + const statusColor = result.passed ? '' : ''; + console.log(`║ ${status.padEnd(8)} ${result.name.padEnd(50)} ║`); + if (!result.passed) allPassed = false; + } + + console.log('╠══════════════════════════════════════════════════════════════╣'); + const overallStatus = allPassed ? '✓ ALL TESTS PASSED' : '✗ SOME TESTS FAILED'; + console.log(`║ ${overallStatus.padEnd(60)} ║`); + console.log('╚══════════════════════════════════════════════════════════════╝'); + + process.exit(allPassed ? 0 : 1); +} + +main().catch(err => { + console.error('Test runner failed:', err); + process.exit(1); +}); diff --git a/tests/stress-test-normal.cjs b/tests/stress-test-normal.cjs new file mode 100755 index 0000000000000000000000000000000000000000..d493afec03399fc42afdae3ebc6f888183afb216 --- /dev/null +++ b/tests/stress-test-normal.cjs @@ -0,0 +1,93 @@ +/** + * Stress Test - Send multiple concurrent requests to test rate limit handling + * + * Usage: node tests/stress-test.cjs [count] [model] + * Example: node tests/stress-test.cjs 10 gemini-3-flash + */ + +const BASE_URL = process.env.ANTHROPIC_BASE_URL || 'http://localhost:8080'; + +const count = parseInt(process.argv[2]) || 8; +const model = process.argv[3] || 'gemini-3-flash'; + +async function sendRequest(id) { + const startTime = Date.now(); + try { + const response = await fetch(`${BASE_URL}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: model, + max_tokens: 100, + messages: [ + { role: 'user', content: `Request ${id}: Say "Hello ${id}" and nothing else.` } + ] + }) + }); + + const elapsed = Date.now() - startTime; + + if (!response.ok) { + const errorText = await response.text(); + console.log(`[${id}] ❌ ${response.status} after ${elapsed}ms: ${errorText.substring(0, 100)}`); + return { id, success: false, status: response.status, elapsed }; + } + + const data = await response.json(); + const text = data.content?.[0]?.text?.substring(0, 50) || 'No text'; + console.log(`[${id}] ✅ 200 after ${elapsed}ms: "${text}..."`); + return { id, success: true, status: 200, elapsed }; + } catch (error) { + const elapsed = Date.now() - startTime; + console.log(`[${id}] ❌ Error after ${elapsed}ms: ${error.message}`); + return { id, success: false, error: error.message, elapsed }; + } +} + +async function runStressTest() { + console.log(`\n🚀 Stress Test: Sending ${count} concurrent requests to ${model}\n`); + console.log(`Target: ${BASE_URL}/v1/messages\n`); + console.log('─'.repeat(70)); + + const startTime = Date.now(); + + // Send all requests concurrently + const promises = []; + for (let i = 1; i <= count; i++) { + promises.push(sendRequest(i)); + } + + const results = await Promise.all(promises); + + const totalElapsed = Date.now() - startTime; + console.log('─'.repeat(70)); + + // Summary + const successful = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + const avgElapsed = Math.round(results.reduce((sum, r) => sum + r.elapsed, 0) / results.length); + + console.log(`\n📊 Summary:`); + console.log(` Total time: ${totalElapsed}ms`); + console.log(` Successful: ${successful}/${count}`); + console.log(` Failed: ${failed}/${count}`); + console.log(` Avg response time: ${avgElapsed}ms`); + + if (failed > 0) { + const errors = results.filter(r => !r.success); + const statusCounts = {}; + errors.forEach(e => { + const key = e.status || 'network'; + statusCounts[key] = (statusCounts[key] || 0) + 1; + }); + console.log(` Error breakdown: ${JSON.stringify(statusCounts)}`); + } + + console.log(''); +} + +runStressTest().catch(console.error); diff --git a/tests/stress-test-streaming.cjs b/tests/stress-test-streaming.cjs new file mode 100755 index 0000000000000000000000000000000000000000..60da4955291079e87d797df6a6206683077eef57 --- /dev/null +++ b/tests/stress-test-streaming.cjs @@ -0,0 +1,141 @@ +/** + * Stress Test (Streaming) - Send multiple concurrent streaming requests to test rate limit handling + * + * Usage: node tests/stress-test-streaming.cjs [count] [model] + * Example: node tests/stress-test-streaming.cjs 10 gemini-3-flash + */ + +const BASE_URL = process.env.ANTHROPIC_BASE_URL || 'http://localhost:8080'; + +const count = parseInt(process.argv[2]) || 8; +const model = process.argv[3] || 'gemini-3-flash'; + +async function sendStreamingRequest(id) { + const startTime = Date.now(); + try { + const response = await fetch(`${BASE_URL}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': 'test', + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: model, + max_tokens: 100, + stream: true, + messages: [ + { role: 'user', content: `Request ${id}: Say "Hello ${id}" and nothing else.` } + ] + }) + }); + + const elapsed = Date.now() - startTime; + + if (!response.ok) { + const errorText = await response.text(); + console.log(`[${id}] ❌ ${response.status} after ${elapsed}ms: ${errorText.substring(0, 100)}`); + return { id, success: false, status: response.status, elapsed }; + } + + // Read the SSE stream + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullText = ''; + let hasThinking = false; + let eventCount = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const event = JSON.parse(data); + eventCount++; + + // Extract text or thinking from content_block_delta events + if (event.type === 'content_block_delta') { + if (event.delta?.text) { + fullText += event.delta.text; + } else if (event.delta?.thinking) { + hasThinking = true; + } + } + } catch (e) { + // Ignore parse errors for partial chunks + } + } + } + } + + const totalElapsed = Date.now() - startTime; + const hasContent = fullText.length > 0 || hasThinking; + + if (!hasContent) { + console.log(`[${id}] ⚠️ ${response.status} after ${totalElapsed}ms (${eventCount} events): No content received`); + return { id, success: false, status: response.status, elapsed: totalElapsed, eventCount }; + } + + const textPreview = fullText.substring(0, 50) || '(thinking only)'; + console.log(`[${id}] ✅ ${response.status} after ${totalElapsed}ms (${eventCount} events): "${textPreview}..."`); + return { id, success: true, status: response.status, elapsed: totalElapsed, eventCount }; + } catch (error) { + const elapsed = Date.now() - startTime; + console.log(`[${id}] ❌ Error after ${elapsed}ms: ${error.message}`); + return { id, success: false, error: error.message, elapsed }; + } +} + +async function runStressTest() { + console.log(`\n🚀 Stress Test (STREAMING): Sending ${count} concurrent requests to ${model}\n`); + console.log(`Target: ${BASE_URL}/v1/messages (stream=true)\n`); + console.log('─'.repeat(70)); + + const startTime = Date.now(); + + // Send all requests concurrently + const promises = []; + for (let i = 1; i <= count; i++) { + promises.push(sendStreamingRequest(i)); + } + + const results = await Promise.all(promises); + + const totalElapsed = Date.now() - startTime; + console.log('─'.repeat(70)); + + // Summary + const successful = results.filter(r => r.success).length; + const failed = results.filter(r => !r.success).length; + const avgElapsed = Math.round(results.reduce((sum, r) => sum + r.elapsed, 0) / results.length); + const totalEvents = results.filter(r => r.success).reduce((sum, r) => sum + (r.eventCount || 0), 0); + + console.log(`\n📊 Summary:`); + console.log(` Total time: ${totalElapsed}ms`); + console.log(` Successful: ${successful}/${count}`); + console.log(` Failed: ${failed}/${count}`); + console.log(` Avg response time: ${avgElapsed}ms`); + console.log(` Total SSE events: ${totalEvents}`); + + if (failed > 0) { + const errors = results.filter(r => !r.success); + const statusCounts = {}; + errors.forEach(e => { + const key = e.status || 'network'; + statusCounts[key] = (statusCounts[key] || 0) + 1; + }); + console.log(` Error breakdown: ${JSON.stringify(statusCounts)}`); + } + + console.log(''); +} + +runStressTest().catch(console.error); diff --git a/tests/test-403-account-rotation.cjs b/tests/test-403-account-rotation.cjs new file mode 100755 index 0000000000000000000000000000000000000000..15ec35637d8b757b0462af522f106ace212a5371 --- /dev/null +++ b/tests/test-403-account-rotation.cjs @@ -0,0 +1,442 @@ +/** + * Test for 403 VALIDATION_REQUIRED Account Rotation (Issue #248) + * + * Validates that when an account receives a 403 PERMISSION_DENIED / VALIDATION_REQUIRED + * error, the proxy rotates to the next account instead of only cycling through endpoints. + * + * Tests: + * 1. isValidationRequired() correctly detects account-level 403 errors + * 2. isAccountForbiddenError() correctly classifies errors for account rotation + * 3. AccountForbiddenError class works correctly + * 4. Non-validation 403 errors (e.g., 404-like) still fall through to endpoint rotation + */ + +async function runTests() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ 403 ACCOUNT ROTATION TEST SUITE (Issue #248) ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Dynamic imports for ESM modules + const { + AccountForbiddenError, + isAccountForbiddenError, + isRateLimitError, + isAuthError + } = await import('../src/errors.js'); + + const { + isValidationRequired, + extractVerificationUrl, + isPermanentAuthFailure, + isModelCapacityExhausted + } = await import('../src/cloudcode/rate-limit-state.js'); + + let passed = 0; + let failed = 0; + + function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } + } + + function assertEqual(actual, expected, message = '') { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`); + } + } + + function assertTrue(value, message = '') { + if (!value) { + throw new Error(message || 'Expected true but got false'); + } + } + + function assertFalse(value, message = '') { + if (value) { + throw new Error(message || 'Expected false but got true'); + } + } + + // ========================================================================= + // Test Group 1: isValidationRequired() detection + // ========================================================================= + console.log('\n── isValidationRequired() Detection ──────────────────────────'); + + test('detects VALIDATION_REQUIRED in error text', () => { + assertTrue(isValidationRequired( + '{"error":{"code":403,"message":"VALIDATION_REQUIRED: Please complete account validation","status":"PERMISSION_DENIED"}}' + )); + }); + + test('does NOT match generic PERMISSION_DENIED (too broad)', () => { + assertFalse(isValidationRequired( + '{"error":{"code":403,"message":"Request had insufficient authentication scopes.","status":"PERMISSION_DENIED"}}' + )); + }); + + test('does NOT match generic "forbidden" (too broad)', () => { + assertFalse(isValidationRequired( + '{"error":{"code":403,"message":"Forbidden: some generic error"}}' + )); + }); + + test('does NOT match generic ACCESS_DENIED (too broad)', () => { + assertFalse(isValidationRequired( + '{"error":{"code":403,"message":"Access denied for this account"}}' + )); + }); + + test('detects ACCOUNT_DISABLED in error text', () => { + assertTrue(isValidationRequired( + '{"error":{"code":403,"message":"account_disabled: This account has been disabled"}}' + )); + }); + + test('detects USER_DISABLED in error text', () => { + assertTrue(isValidationRequired( + '{"error":{"code":403,"message":"user_disabled: User account is disabled"}}' + )); + }); + + test('does NOT detect generic 404 error', () => { + assertFalse(isValidationRequired( + '{"error":{"code":404,"message":"Resource not found"}}' + )); + }); + + test('does NOT detect rate limit errors', () => { + assertFalse(isValidationRequired( + '{"error":{"code":429,"message":"RESOURCE_EXHAUSTED: Quota exceeded"}}' + )); + }); + + test('does NOT detect empty string', () => { + assertFalse(isValidationRequired('')); + }); + + test('does NOT detect null', () => { + assertFalse(isValidationRequired(null)); + }); + + test('does NOT detect capacity exhausted', () => { + assertFalse(isValidationRequired( + '{"error":{"code":429,"message":"MODEL_CAPACITY_EXHAUSTED"}}' + )); + }); + + // ========================================================================= + // Test Group 2: AccountForbiddenError class + // ========================================================================= + console.log('\n── AccountForbiddenError Class ────────────────────────────────'); + + test('creates AccountForbiddenError with correct properties', () => { + const err = new AccountForbiddenError('Test forbidden', 'test@example.com'); + assertEqual(err.name, 'AccountForbiddenError'); + assertEqual(err.code, 'ACCOUNT_FORBIDDEN'); + assertEqual(err.accountEmail, 'test@example.com'); + assertEqual(err.retryable, false); + assertTrue(err.message.includes('Test forbidden')); + }); + + test('AccountForbiddenError is instance of Error', () => { + const err = new AccountForbiddenError('Test'); + assertTrue(err instanceof Error); + }); + + test('AccountForbiddenError toJSON includes metadata', () => { + const err = new AccountForbiddenError('Test', 'user@test.com'); + const json = err.toJSON(); + assertEqual(json.code, 'ACCOUNT_FORBIDDEN'); + assertEqual(json.accountEmail, 'user@test.com'); + }); + + // ========================================================================= + // Test Group 3: isAccountForbiddenError() classification + // ========================================================================= + console.log('\n── isAccountForbiddenError() Classification ───────────────────'); + + test('detects AccountForbiddenError instance', () => { + const err = new AccountForbiddenError('Test'); + assertTrue(isAccountForbiddenError(err)); + }); + + test('detects ACCOUNT_FORBIDDEN in error message', () => { + const err = new Error('ACCOUNT_FORBIDDEN: validation required'); + assertTrue(isAccountForbiddenError(err)); + }); + + test('does NOT match VALIDATION_REQUIRED plain string (use AccountForbiddenError class instead)', () => { + const err = new Error('403 VALIDATION_REQUIRED: Please complete validation'); + assertFalse(isAccountForbiddenError(err)); + }); + + test('does NOT match generic 403 + PERMISSION_DENIED (too broad)', () => { + const err = new Error('API error 403: PERMISSION_DENIED'); + assertFalse(isAccountForbiddenError(err)); + }); + + test('does NOT match generic 403 + FORBIDDEN (too broad)', () => { + const err = new Error('API error 403: Forbidden'); + assertFalse(isAccountForbiddenError(err)); + }); + + test('does NOT classify rate limit errors as forbidden', () => { + const err = new Error('429 RESOURCE_EXHAUSTED'); + assertFalse(isAccountForbiddenError(err)); + }); + + test('does NOT classify auth errors as forbidden', () => { + const err = new Error('AUTH_INVALID: token expired'); + assertFalse(isAccountForbiddenError(err)); + }); + + test('does NOT classify 500 errors as forbidden', () => { + const err = new Error('API error 500: Internal server error'); + assertFalse(isAccountForbiddenError(err)); + }); + + test('does NOT classify generic errors as forbidden', () => { + const err = new Error('Something went wrong'); + assertFalse(isAccountForbiddenError(err)); + }); + + // ========================================================================= + // Test Group 4: Error classification mutual exclusivity + // ========================================================================= + console.log('\n── Error Classification Mutual Exclusivity ────────────────────'); + + test('ACCOUNT_FORBIDDEN is not a rate limit error', () => { + const err = new AccountForbiddenError('Test'); + assertFalse(isRateLimitError(err)); + }); + + test('ACCOUNT_FORBIDDEN is not an auth error', () => { + const err = new AccountForbiddenError('Test'); + assertFalse(isAuthError(err)); + }); + + test('rate limit error is not account forbidden', () => { + const err = new Error('429 RESOURCE_EXHAUSTED: quota exceeded'); + assertTrue(isRateLimitError(err)); + assertFalse(isAccountForbiddenError(err)); + }); + + test('auth error is not account forbidden', () => { + const err = new Error('AUTH_INVALID: invalid grant'); + assertTrue(isAuthError(err)); + assertFalse(isAccountForbiddenError(err)); + }); + + // ========================================================================= + // Test Group 5: isValidationRequired vs other detectors (no overlap) + // ========================================================================= + console.log('\n── Detector Mutual Exclusivity ─────────────────────────────────'); + + test('VALIDATION_REQUIRED is not permanent auth failure', () => { + const text = '{"error":{"code":403,"message":"VALIDATION_REQUIRED","status":"PERMISSION_DENIED"}}'; + assertTrue(isValidationRequired(text)); + assertFalse(isPermanentAuthFailure(text)); + }); + + test('VALIDATION_REQUIRED is not model capacity exhausted', () => { + const text = '{"error":{"code":403,"message":"VALIDATION_REQUIRED","status":"PERMISSION_DENIED"}}'; + assertTrue(isValidationRequired(text)); + assertFalse(isModelCapacityExhausted(text)); + }); + + test('permanent auth failure is not validation required (unless it also says permission_denied)', () => { + const text = '{"error":{"message":"invalid_grant: Token has been expired or revoked"}}'; + assertTrue(isPermanentAuthFailure(text)); + // This should NOT be validation required since it's a token issue + assertFalse(isValidationRequired(text)); + }); + + // ========================================================================= + // Test Group 6: extractVerificationUrl() + // ========================================================================= + console.log('\n── extractVerificationUrl() ──────────────────────────────────'); + + test('extracts Google verification URL from error message', () => { + const errorText = '{"error":{"code":403,"message":"To continue, verify your account at\\n\\nhttps://accounts.google.com/signin/continue?sarp=1&scc=1&continue=https://aistudio.google.com\\n\\nLearn more","status":"PERMISSION_DENIED"}}'; + const url = extractVerificationUrl(errorText); + assertTrue(url !== null, 'Should extract URL'); + assertTrue(url.startsWith('https://accounts.google.com/signin/continue'), 'URL should start with Google signin'); + }); + + test('extracts URL with complex query parameters', () => { + const errorText = 'Error: https://accounts.google.com/signin/continue?sarp=1&scc=1&continue=https%3A%2F%2Faistudio.google.com&foo=bar end'; + const url = extractVerificationUrl(errorText); + assertTrue(url !== null, 'Should extract URL'); + assertTrue(url.includes('sarp=1'), 'Should include query params'); + }); + + test('returns null when no verification URL present', () => { + const errorText = '{"error":{"code":403,"message":"Generic permission denied","status":"PERMISSION_DENIED"}}'; + const url = extractVerificationUrl(errorText); + assertEqual(url, null, 'Should return null for no URL'); + }); + + test('returns null for empty string', () => { + assertEqual(extractVerificationUrl(''), null); + }); + + test('returns null for null input', () => { + assertEqual(extractVerificationUrl(null), null); + }); + + test('extracts validation_url from structured JSON details', () => { + const errorText = JSON.stringify({ + error: { + code: 403, + message: "Verify your account to continue.", + status: "PERMISSION_DENIED", + details: [{ + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "VALIDATION_REQUIRED", + domain: "cloudcode-pa.googleapis.com", + metadata: { + validation_url: "https://accounts.google.com/signin/continue?sarp=1&scc=1&continue=https://developers.google.com/gemini-code-assist/auth/auth_success_gemini&plt=AKgnsbuSMNHioDdPwkLy&flowName=GlifWebSignIn&authuser" + } + }] + } + }); + const url = extractVerificationUrl(errorText); + assertTrue(url !== null, 'Should extract URL from structured details'); + assertTrue(url.startsWith('https://accounts.google.com/signin/continue'), 'URL should start with Google signin'); + assertTrue(url.includes('flowName=GlifWebSignIn'), 'Should preserve full URL from metadata'); + }); + + // ========================================================================= + // Test Group 7: Real-world 403 error payloads + // ========================================================================= + console.log('\n── Real-world 403 Error Payloads ────────────────────────────────'); + + test('Google API PERMISSION_DENIED with VALIDATION_REQUIRED', () => { + const errorText = JSON.stringify({ + error: { + code: 403, + message: "Request had insufficient authentication scopes.", + status: "PERMISSION_DENIED", + details: [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason: "VALIDATION_REQUIRED", + domain: "googleapis.com" + } + ] + } + }); + assertTrue(isValidationRequired(errorText)); + }); + + test('Google API generic PERMISSION_DENIED (should NOT trigger - too broad)', () => { + const errorText = JSON.stringify({ + error: { + code: 403, + message: "The caller does not have permission", + status: "PERMISSION_DENIED" + } + }); + assertFalse(isValidationRequired(errorText)); + }); + + test('Google API FORBIDDEN response (should NOT trigger - too broad)', () => { + const errorText = JSON.stringify({ + error: { + code: 403, + message: "Forbidden", + status: "FORBIDDEN" + } + }); + assertFalse(isValidationRequired(errorText)); + }); + + // ========================================================================= + // Test Group 8: markInvalid() with verifyUrl (integration) + // ========================================================================= + console.log('\n── markInvalid() with verifyUrl ──────────────────────────────'); + + const { markInvalid } = await import('../src/account-manager/rate-limits.js'); + + test('markInvalid stores verifyUrl as separate field', () => { + const accounts = [{ email: 'test@example.com', enabled: true }]; + const verifyUrl = 'https://accounts.google.com/signin/continue?sarp=1&scc=1'; + markInvalid(accounts, 'test@example.com', 'Account requires verification', verifyUrl); + assertTrue(accounts[0].isInvalid, 'isInvalid should be true'); + assertEqual(accounts[0].invalidReason, 'Account requires verification'); + assertEqual(accounts[0].verifyUrl, verifyUrl, 'verifyUrl should be stored separately'); + }); + + test('markInvalid without verifyUrl sets verifyUrl to null', () => { + const accounts = [{ email: 'test@example.com', enabled: true }]; + markInvalid(accounts, 'test@example.com', 'Token revoked'); + assertTrue(accounts[0].isInvalid, 'isInvalid should be true'); + assertEqual(accounts[0].verifyUrl, null, 'verifyUrl should be null for auth errors'); + }); + + test('markInvalid sets invalidAt timestamp', () => { + const accounts = [{ email: 'test@example.com', enabled: true }]; + const before = Date.now(); + markInvalid(accounts, 'test@example.com', 'Test reason'); + assertTrue(accounts[0].invalidAt >= before, 'invalidAt should be set'); + assertTrue(accounts[0].invalidAt <= Date.now(), 'invalidAt should be reasonable'); + }); + + test('markInvalid returns false for unknown email', () => { + const accounts = [{ email: 'test@example.com', enabled: true }]; + const result = markInvalid(accounts, 'unknown@example.com', 'Test'); + assertFalse(result, 'Should return false for unknown email'); + }); + + test('verification error can be cleared by resetting fields', () => { + const accounts = [{ email: 'test@example.com', enabled: true }]; + const verifyUrl = 'https://accounts.google.com/signin/continue?sarp=1'; + markInvalid(accounts, 'test@example.com', 'Account requires verification', verifyUrl); + + // Simulate what the refresh endpoint does for verification errors + assertTrue(accounts[0].isInvalid && accounts[0].verifyUrl, 'Should have verifyUrl'); + accounts[0].isInvalid = false; + accounts[0].invalidReason = null; + accounts[0].verifyUrl = null; + + assertFalse(accounts[0].isInvalid, 'isInvalid should be cleared'); + assertEqual(accounts[0].verifyUrl, null, 'verifyUrl should be cleared'); + }); + + test('auth error (no verifyUrl) is NOT cleared by refresh logic', () => { + const accounts = [{ email: 'test@example.com', enabled: true }]; + markInvalid(accounts, 'test@example.com', 'Token revoked'); + + // Simulate refresh endpoint check: only clears if verifyUrl exists + const shouldClear = accounts[0].isInvalid && accounts[0].verifyUrl; + assertFalse(shouldClear, 'Auth errors should NOT be auto-cleared on refresh'); + assertTrue(accounts[0].isInvalid, 'isInvalid should remain true'); + }); + + // ========================================================================= + // Summary + // ========================================================================= + console.log('\n══════════════════════════════════════════════════════════════'); + console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`); + console.log('══════════════════════════════════════════════════════════════\n'); + + if (failed > 0) { + console.error(`❌ ${failed} test(s) failed!`); + process.exit(1); + } else { + console.log('✅ All tests passed!'); + process.exit(0); + } +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +}); diff --git a/tests/test-403-integration.cjs b/tests/test-403-integration.cjs new file mode 100755 index 0000000000000000000000000000000000000000..b63a500f5cda41690cbf32cd5eae6e2643953c51 --- /dev/null +++ b/tests/test-403-integration.cjs @@ -0,0 +1,427 @@ +/** + * Integration Test: 403 Account Rotation Handler Flow + * + * Tests the ACTUAL handler control flow for 403 VALIDATION_REQUIRED errors, + * verifying that: + * 1. Account is marked invalid with verifyUrl when 403 + VALIDATION_REQUIRED + * 2. Handler rotates to next account (doesn't retry same account) + * 3. Generic 403s fall through to endpoint rotation (not account rotation) + * 4. clearInvalid() properly re-enables accounts after verification + * 5. WebUI refresh endpoint uses AccountManager.clearInvalid() (encapsulated) + */ + +const { strict: assert } = require('assert'); + +// ============================================================================ +// Mock infrastructure +// ============================================================================ + +/** Minimal AccountManager mock that tracks all mutations */ +class MockAccountManager { + constructor(accounts) { + this._accounts = accounts.map(a => ({ + ...a, + isInvalid: false, + invalidReason: null, + verifyUrl: null, + modelRateLimits: {}, + consecutiveFailures: 0, + enabled: true, + })); + this._log = []; // Track all method calls for assertions + this._savedToDisk = 0; + } + + getAvailableAccounts(model) { + return this._accounts.filter(a => !a.isInvalid && a.enabled !== false); + } + + getAllAccounts() { + return this._accounts; + } + + isAllAccountsInvalid() { + const enabled = this._accounts.filter(a => a.enabled !== false); + return enabled.length > 0 && enabled.every(a => a.isInvalid); + } + + markInvalid(email, reason, verifyUrl = null) { + const acc = this._accounts.find(a => a.email === email); + if (!acc) return false; + acc.isInvalid = true; + acc.invalidReason = reason; + acc.verifyUrl = verifyUrl || null; + acc.invalidAt = Date.now(); + this._log.push({ action: 'markInvalid', email, reason, verifyUrl }); + return true; + } + + clearInvalid(email) { + const acc = this._accounts.find(a => a.email === email); + if (!acc) return false; + acc.isInvalid = false; + acc.invalidReason = null; + acc.verifyUrl = null; + this._log.push({ action: 'clearInvalid', email }); + this._savedToDisk++; + return true; + } + + markRateLimited(email, resetMs, model) { + this._log.push({ action: 'markRateLimited', email, resetMs, model }); + } + + notifySuccess(account, model) { + this._log.push({ action: 'notifySuccess', email: account.email, model }); + } + + notifyFailure(account, model) { + this._log.push({ action: 'notifyFailure', email: account.email, model }); + } + + notifyRateLimit(account, model) { + this._log.push({ action: 'notifyRateLimit', email: account.email, model }); + } + + getConsecutiveFailures(email) { return 0; } + incrementConsecutiveFailures(email) { return 1; } + saveToDisk() { this._savedToDisk++; } + clearTokenCache() {} + clearProjectCache() {} +} + +// Import the actual detection/classification functions +const { + isValidationRequired, + extractVerificationUrl, + isPermanentAuthFailure, +} = require('../src/cloudcode/rate-limit-state.js'); + +const { + AccountForbiddenError, + isAccountForbiddenError, + isRateLimitError, + isAuthError, +} = require('../src/errors.js'); + +// ============================================================================ +// Test runner +// ============================================================================ + +let passed = 0; +let failed = 0; +const failures = []; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✓ ${name}`); + } catch (e) { + failed++; + failures.push({ name, error: e.message }); + console.log(` ✗ ${name}`); + console.log(` ${e.message}`); + } +} + +// ============================================================================ +// Integration Tests +// ============================================================================ + +console.log('╔══════════════════════════════════════════════════════════════╗'); +console.log('║ INTEGRATION TEST: 403 Account Rotation Handler Flow ║'); +console.log('╚══════════════════════════════════════════════════════════════╝\n'); + +// ───────────────────────────────────────────────────────────────────────────── +// Test Group 1: Handler-level account rotation on 403 VALIDATION_REQUIRED +// ───────────────────────────────────────────────────────────────────────────── +console.log('── Handler: 403 VALIDATION_REQUIRED → account rotation ────────'); + +test('403 + VALIDATION_REQUIRED marks account invalid and rotates', () => { + const mgr = new MockAccountManager([ + { email: 'a@test.com' }, + { email: 'b@test.com' }, + ]); + + // Simulate what the handler does on 403 + VALIDATION_REQUIRED + const errorText = '{"error":{"code":403,"message":"VALIDATION_REQUIRED: Please verify","status":"PERMISSION_DENIED"}}'; + const account = mgr.getAvailableAccounts()[0]; + + assert.ok(isValidationRequired(errorText), 'should detect VALIDATION_REQUIRED'); + + const verifyUrl = extractVerificationUrl(errorText); + mgr.markInvalid(account.email, 'Account requires verification', verifyUrl); + + const err = new AccountForbiddenError(errorText, account.email); + assert.ok(isAccountForbiddenError(err), 'should classify as account forbidden'); + + // Verify account was marked invalid + assert.strictEqual(account.isInvalid, true, 'account should be invalid'); + assert.strictEqual(account.invalidReason, 'Account requires verification'); + + // Verify second account is still available for rotation + const available = mgr.getAvailableAccounts(); + assert.strictEqual(available.length, 1, 'should have 1 available account'); + assert.strictEqual(available[0].email, 'b@test.com', 'should rotate to b@test.com'); + + // Verify mutation was logged + const markCall = mgr._log.find(l => l.action === 'markInvalid'); + assert.ok(markCall, 'markInvalid should have been called'); + assert.strictEqual(markCall.email, 'a@test.com'); +}); + +test('403 + VALIDATION_REQUIRED with verification URL stores it', () => { + const mgr = new MockAccountManager([{ email: 'a@test.com' }]); + const errorText = '403 VALIDATION_REQUIRED: Visit https://accounts.google.com/signin/continue?sarp=1&scc=1&otp=abc123 to verify'; + + const verifyUrl = extractVerificationUrl(errorText); + assert.ok(verifyUrl, 'should extract verification URL'); + assert.ok(verifyUrl.startsWith('https://accounts.google.com/signin/continue'), 'URL should be Google verification'); + assert.ok(!verifyUrl.endsWith('.'), 'URL should not end with punctuation'); + + mgr.markInvalid('a@test.com', 'Account requires verification', verifyUrl); + const acc = mgr.getAllAccounts()[0]; + assert.strictEqual(acc.verifyUrl, verifyUrl, 'verifyUrl should be stored'); +}); + +test('generic 403 (no VALIDATION_REQUIRED) does NOT trigger account rotation', () => { + const errorText = '{"error":{"code":403,"message":"Forbidden","status":"FORBIDDEN"}}'; + + assert.strictEqual(isValidationRequired(errorText), false, 'generic 403 should not be validation required'); + + // Handler would NOT create AccountForbiddenError, just fall through to endpoint rotation + const genericErr = new Error(`API error 403: ${errorText}`); + assert.strictEqual(isAccountForbiddenError(genericErr), false, 'generic 403 should not be account forbidden'); +}); + +test('403 PERMISSION_DENIED without VALIDATION_REQUIRED does NOT trigger account rotation', () => { + const errorText = '{"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}'; + + assert.strictEqual(isValidationRequired(errorText), false, 'generic PERMISSION_DENIED should not trigger'); + const err = new Error(`API error 403: ${errorText}`); + assert.strictEqual(isAccountForbiddenError(err), false, 'should not be classified as account forbidden'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test Group 2: Multi-account rotation chain +// ───────────────────────────────────────────────────────────────────────────── +console.log('\n── Handler: Multi-account rotation chain ──────────────────────'); + +test('all accounts marked invalid → no accounts available', () => { + const mgr = new MockAccountManager([ + { email: 'a@test.com' }, + { email: 'b@test.com' }, + { email: 'c@test.com' }, + ]); + + // Simulate all accounts hitting VALIDATION_REQUIRED + for (const acc of mgr.getAllAccounts()) { + mgr.markInvalid(acc.email, 'Account requires verification'); + } + + const available = mgr.getAvailableAccounts(); + assert.strictEqual(available.length, 0, 'no accounts should be available'); +}); + +test('one account invalid, others still available for rotation', () => { + const mgr = new MockAccountManager([ + { email: 'a@test.com' }, + { email: 'b@test.com' }, + { email: 'c@test.com' }, + ]); + + mgr.markInvalid('b@test.com', 'Account requires verification'); + + const available = mgr.getAvailableAccounts(); + assert.strictEqual(available.length, 2, 'should have 2 available accounts'); + assert.ok(available.every(a => a.email !== 'b@test.com'), 'b@test.com should be excluded'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test Group 3: clearInvalid() re-enables accounts (WebUI refresh flow) +// ───────────────────────────────────────────────────────────────────────────── +console.log('\n── Handler: clearInvalid() re-enables accounts (WebUI flow) ───'); + +test('clearInvalid() re-enables account after verification', () => { + const mgr = new MockAccountManager([{ email: 'a@test.com' }]); + + // Mark invalid + mgr.markInvalid('a@test.com', 'Account requires verification', 'https://accounts.google.com/signin/continue?sarp=1'); + assert.strictEqual(mgr.getAvailableAccounts().length, 0, 'should have 0 available'); + + // Simulate WebUI refresh endpoint (user completed verification) + mgr.clearInvalid('a@test.com'); + assert.strictEqual(mgr.getAvailableAccounts().length, 1, 'should have 1 available after clear'); + + const acc = mgr.getAllAccounts()[0]; + assert.strictEqual(acc.isInvalid, false, 'isInvalid should be false'); + assert.strictEqual(acc.invalidReason, null, 'invalidReason should be null'); + assert.strictEqual(acc.verifyUrl, null, 'verifyUrl should be null'); +}); + +test('clearInvalid() persists to disk via saveToDisk()', () => { + const mgr = new MockAccountManager([{ email: 'a@test.com' }]); + mgr.markInvalid('a@test.com', 'test'); + + const beforeSaves = mgr._savedToDisk; + mgr.clearInvalid('a@test.com'); + assert.ok(mgr._savedToDisk > beforeSaves, 'should save to disk after clearInvalid'); +}); + +test('WebUI refresh does NOT clear auth errors (only verification errors)', () => { + const mgr = new MockAccountManager([{ email: 'a@test.com' }]); + + // Auth error (no verifyUrl) — requires OAuth re-auth, NOT just refresh + mgr.markInvalid('a@test.com', 'Token revoked', null); + + const acc = mgr.getAllAccounts()[0]; + // WebUI refresh logic: only clear if account has verifyUrl + if (acc.isInvalid && acc.verifyUrl) { + mgr.clearInvalid(acc.email); + } + // Should NOT have been cleared — no verifyUrl + assert.strictEqual(acc.isInvalid, true, 'auth error should NOT be cleared by refresh'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test Group 4: Error classification doesn't interfere with other handlers +// ───────────────────────────────────────────────────────────────────────────── +console.log('\n── Handler: Error classification isolation ─────────────────────'); + +test('AccountForbiddenError does not trigger rate limit handler', () => { + const err = new AccountForbiddenError('403 VALIDATION_REQUIRED', 'a@test.com'); + assert.strictEqual(isRateLimitError(err), false, 'should not be rate limit'); + assert.strictEqual(isAuthError(err), false, 'should not be auth error'); + assert.strictEqual(isAccountForbiddenError(err), true, 'should be account forbidden'); +}); + +test('rate limit errors do not trigger account forbidden handler', () => { + const err = new Error('429 RESOURCE_EXHAUSTED: quota exceeded'); + assert.strictEqual(isAccountForbiddenError(err), false); + assert.strictEqual(isRateLimitError(err), true); +}); + +test('permanent auth failure is distinct from validation required', () => { + const authText = 'invalid_grant: Token has been expired or revoked'; + const validText = '403 VALIDATION_REQUIRED: Please verify'; + + assert.strictEqual(isPermanentAuthFailure(authText), true); + assert.strictEqual(isValidationRequired(authText), false); + assert.strictEqual(isPermanentAuthFailure(validText), false); + assert.strictEqual(isValidationRequired(validText), true); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test Group 5: Edge cases in handler flow +// ───────────────────────────────────────────────────────────────────────────── +console.log('\n── Handler: Edge cases ────────────────────────────────────────'); + +test('ACCOUNT_DISABLED triggers account rotation (same as VALIDATION_REQUIRED)', () => { + const errorText = '{"error":{"code":403,"message":"ACCOUNT_DISABLED","status":"PERMISSION_DENIED"}}'; + assert.strictEqual(isValidationRequired(errorText), true); + + const mgr = new MockAccountManager([{ email: 'a@test.com' }, { email: 'b@test.com' }]); + mgr.markInvalid('a@test.com', 'Account disabled'); + assert.strictEqual(mgr.getAvailableAccounts().length, 1); +}); + +test('USER_DISABLED triggers account rotation', () => { + const errorText = 'user_disabled: This account has been suspended'; + assert.strictEqual(isValidationRequired(errorText), true); +}); + +test('mixed case detection works', () => { + assert.strictEqual(isValidationRequired('Validation_Required'), true); + assert.strictEqual(isValidationRequired('ACCOUNT_DISABLED'), true); + assert.strictEqual(isValidationRequired('User_Disabled'), true); +}); + +test('extractVerificationUrl handles URL with trailing bracket', () => { + const text = 'Visit [https://accounts.google.com/signin/continue?sarp=1&scc=1] to verify'; + const url = extractVerificationUrl(text); + assert.ok(url, 'should extract URL'); + assert.ok(!url.includes(']'), 'URL should not include trailing bracket'); +}); + +test('extractVerificationUrl preserves dots inside URL (subdomain in query param)', () => { + const text = 'Visit https://accounts.google.com/signin/continue?sarp=1&scc=1&continue=https://aistudio.google.com to verify'; + const url = extractVerificationUrl(text); + assert.ok(url, 'should extract URL'); + assert.ok(url.includes('aistudio.google.com'), 'URL should preserve subdomain dots'); +}); + +test('extractVerificationUrl handles URL with trailing angle bracket', () => { + const text = 'Visit to verify'; + const url = extractVerificationUrl(text); + assert.ok(url, 'should extract URL'); + assert.ok(!url.includes('>'), 'URL should not include trailing angle bracket'); +}); + +test('extractVerificationUrl handles URL with trailing brace', () => { + const text = '{"url":"https://accounts.google.com/signin/continue?sarp=1&scc=1"}'; + const url = extractVerificationUrl(text); + assert.ok(url, 'should extract URL'); + assert.ok(!url.endsWith('"'), 'URL should not include trailing quote'); + assert.ok(!url.endsWith('}'), 'URL should not include trailing brace'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test Group 6: isAllAccountsInvalid() — prevents infinite wait loop +// ───────────────────────────────────────────────────────────────────────────── +console.log('\n── Handler: isAllAccountsInvalid() ─────────────────────────────'); + +test('all accounts invalid → isAllAccountsInvalid returns true', () => { + const mgr = new MockAccountManager([ + { email: 'a@test.com' }, + { email: 'b@test.com' }, + ]); + mgr.markInvalid('a@test.com', 'Account requires verification'); + mgr.markInvalid('b@test.com', 'Account requires verification'); + assert.strictEqual(mgr.isAllAccountsInvalid(), true); + assert.strictEqual(mgr.getAvailableAccounts().length, 0); +}); + +test('one invalid, one healthy → isAllAccountsInvalid returns false', () => { + const mgr = new MockAccountManager([ + { email: 'a@test.com' }, + { email: 'b@test.com' }, + ]); + mgr.markInvalid('a@test.com', 'Account requires verification'); + assert.strictEqual(mgr.isAllAccountsInvalid(), false); +}); + +test('one invalid, one disabled → isAllAccountsInvalid returns true', () => { + const mgr = new MockAccountManager([ + { email: 'a@test.com' }, + { email: 'b@test.com' }, + ]); + mgr._accounts[1].enabled = false; + mgr.markInvalid('a@test.com', 'Account requires verification'); + assert.strictEqual(mgr.isAllAccountsInvalid(), true, 'disabled accounts dont count as healthy'); +}); + +test('clearInvalid makes isAllAccountsInvalid return false', () => { + const mgr = new MockAccountManager([{ email: 'a@test.com' }]); + mgr.markInvalid('a@test.com', 'Account requires verification'); + assert.strictEqual(mgr.isAllAccountsInvalid(), true); + mgr.clearInvalid('a@test.com'); + assert.strictEqual(mgr.isAllAccountsInvalid(), false); +}); + +// ============================================================================ +// Results +// ============================================================================ + +console.log('\n══════════════════════════════════════════════════════════════'); +console.log(`Results: ${passed} passed, ${failed} failed, ${passed + failed} total`); +console.log('══════════════════════════════════════════════════════════════\n'); + +if (failures.length > 0) { + console.log('Failures:'); + failures.forEach(f => console.log(` ✗ ${f.name}: ${f.error}`)); + process.exit(1); +} else { + console.log('✅ All integration tests passed!'); + process.exit(0); +} diff --git a/tests/test-cache-control.cjs b/tests/test-cache-control.cjs new file mode 100755 index 0000000000000000000000000000000000000000..c349ce44db69d6943b86dc9834572fefb9ec28ea --- /dev/null +++ b/tests/test-cache-control.cjs @@ -0,0 +1,297 @@ +/** + * Cache Control Field Test (Issue #189) + * + * Tests that cache_control fields on content blocks are properly stripped + * before being sent to the Cloud Code API. + * + * Claude Code CLI sends cache_control on text, thinking, tool_use, tool_result, + * image, and document blocks for prompt caching optimization. The Cloud Code API + * rejects these with "Extra inputs are not permitted". + * + * This test verifies that: + * 1. Text blocks with cache_control work correctly + * 2. Multi-turn conversations with cache_control on assistant content work + * 3. Tool_result blocks with cache_control work correctly + * + * Runs for both Claude and Gemini model families. + */ +const { streamRequest, analyzeContent, commonTools } = require('./helpers/http-client.cjs'); +const { getTestModels, getModelConfig } = require('./helpers/test-models.cjs'); + +const tools = [commonTools.getWeather]; + +async function runTestsForModel(family, model) { + console.log('='.repeat(60)); + console.log(`CACHE CONTROL TEST [${family.toUpperCase()}]`); + console.log(`Model: ${model}`); + console.log('Tests that cache_control fields are stripped from all block types'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + const modelConfig = getModelConfig(family); + + // ===== TEST 1: User text block with cache_control ===== + console.log('TEST 1: User text block with cache_control'); + console.log('-'.repeat(40)); + + try { + const test1Result = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + thinking: modelConfig.thinking, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'What is the capital of France? Reply in one word.', + cache_control: { type: 'ephemeral' } + } + ] + } + ] + }); + + const hasError1 = test1Result.events.some(e => e.type === 'error'); + const errorMsg1 = hasError1 + ? test1Result.events.find(e => e.type === 'error')?.data?.error?.message + : null; + + console.log(` Response received: ${test1Result.content.length > 0 ? 'YES' : 'NO'}`); + console.log(` Has error: ${hasError1 ? 'YES' : 'NO'}`); + if (hasError1) { + console.log(` Error message: ${errorMsg1}`); + } + + const content1 = analyzeContent(test1Result.content); + if (content1.hasText) { + console.log(` Response preview: "${content1.text[0].text.substring(0, 50)}..."`); + } + + const test1Pass = !hasError1 && test1Result.content.length > 0; + results.push({ name: 'User text block with cache_control', passed: test1Pass }); + console.log(` Result: ${test1Pass ? 'PASS' : 'FAIL'}`); + if (!test1Pass) allPassed = false; + } catch (err) { + console.log(` ERROR: ${err.message}`); + results.push({ name: 'User text block with cache_control', passed: false }); + allPassed = false; + } + + // ===== TEST 2: Multi-turn with cache_control on assistant content ===== + console.log('\nTEST 2: Multi-turn with cache_control on assistant content'); + console.log('-'.repeat(40)); + + try { + // First turn - get a response + const turn1 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + thinking: modelConfig.thinking, + messages: [ + { role: 'user', content: 'Say hello.' } + ] + }); + + if (turn1.content.length === 0) { + console.log(' SKIPPED - Turn 1 returned empty response'); + results.push({ name: 'Multi-turn with cache_control', passed: false, skipped: true }); + } else { + // Add cache_control to ALL blocks in assistant response (simulating Claude Code) + const modifiedContent = turn1.content.map(block => ({ + ...block, + cache_control: { type: 'ephemeral' } + })); + + // Second turn - use modified content with cache_control + const turn2 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + thinking: modelConfig.thinking, + messages: [ + { role: 'user', content: 'Say hello.' }, + { role: 'assistant', content: modifiedContent }, + { + role: 'user', + content: [ + { + type: 'text', + text: 'Now say goodbye.', + cache_control: { type: 'ephemeral' } + } + ] + } + ] + }); + + const hasError2 = turn2.events.some(e => e.type === 'error'); + const errorMsg2 = hasError2 + ? turn2.events.find(e => e.type === 'error')?.data?.error?.message + : null; + + console.log(` Turn 1 blocks: ${turn1.content.length}`); + console.log(` Turn 2 response received: ${turn2.content.length > 0 ? 'YES' : 'NO'}`); + console.log(` Has error: ${hasError2 ? 'YES' : 'NO'}`); + if (hasError2) { + console.log(` Error message: ${errorMsg2}`); + // Check specifically for cache_control error + if (errorMsg2 && errorMsg2.includes('cache_control')) { + console.log(' >>> cache_control field NOT stripped properly! <<<'); + } + } + + const content2 = analyzeContent(turn2.content); + if (content2.hasText) { + console.log(` Response preview: "${content2.text[0].text.substring(0, 50)}..."`); + } + + const test2Pass = !hasError2 && turn2.content.length > 0; + results.push({ name: 'Multi-turn with cache_control', passed: test2Pass }); + console.log(` Result: ${test2Pass ? 'PASS' : 'FAIL'}`); + if (!test2Pass) allPassed = false; + } + } catch (err) { + console.log(` ERROR: ${err.message}`); + results.push({ name: 'Multi-turn with cache_control', passed: false }); + allPassed = false; + } + + // ===== TEST 3: Tool loop with cache_control on tool_result ===== + console.log('\nTEST 3: Tool loop with cache_control on tool_result'); + console.log('-'.repeat(40)); + + try { + // First turn - request tool use + const toolTurn1 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages: [ + { role: 'user', content: 'What is the weather in Tokyo? Use the get_weather tool.' } + ] + }); + + const content3a = analyzeContent(toolTurn1.content); + + if (!content3a.hasToolUse) { + console.log(' SKIPPED - Model did not use tool in turn 1'); + results.push({ name: 'Tool_result with cache_control', passed: true, skipped: true }); + } else { + const toolUseId = content3a.toolUse[0].id; + console.log(` Tool use ID: ${toolUseId}`); + + // Second turn - provide tool result with cache_control + const toolTurn2 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages: [ + { role: 'user', content: 'What is the weather in Tokyo? Use the get_weather tool.' }, + { role: 'assistant', content: toolTurn1.content }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: 'The weather in Tokyo is 22°C and partly cloudy.', + cache_control: { type: 'ephemeral' } + } + ] + } + ] + }); + + const hasError3 = toolTurn2.events.some(e => e.type === 'error'); + const errorMsg3 = hasError3 + ? toolTurn2.events.find(e => e.type === 'error')?.data?.error?.message + : null; + + console.log(` Turn 2 response received: ${toolTurn2.content.length > 0 ? 'YES' : 'NO'}`); + console.log(` Has error: ${hasError3 ? 'YES' : 'NO'}`); + if (hasError3) { + console.log(` Error message: ${errorMsg3}`); + if (errorMsg3 && errorMsg3.includes('cache_control')) { + console.log(' >>> cache_control field NOT stripped properly! <<<'); + } + } + + const content3b = analyzeContent(toolTurn2.content); + if (content3b.hasText) { + console.log(` Response preview: "${content3b.text[0].text.substring(0, 50)}..."`); + } + + const test3Pass = !hasError3 && toolTurn2.content.length > 0; + results.push({ name: 'Tool_result with cache_control', passed: test3Pass }); + console.log(` Result: ${test3Pass ? 'PASS' : 'FAIL'}`); + if (!test3Pass) allPassed = false; + } + } catch (err) { + console.log(` ERROR: ${err.message}`); + results.push({ name: 'Tool_result with cache_control', passed: false }); + allPassed = false; + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log(`SUMMARY [${family.toUpperCase()}]`); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.skipped ? 'SKIP' : (result.passed ? 'PASS' : 'FAIL'); + console.log(` [${status}] ${result.name}`); + } + + const passedCount = results.filter(r => r.passed && !r.skipped).length; + const skippedCount = results.filter(r => r.skipped).length; + const totalTests = results.length - skippedCount; + + console.log('\n' + '='.repeat(60)); + console.log(`[${family.toUpperCase()}] ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'} (${passedCount}/${totalTests})`); + console.log('='.repeat(60)); + + return allPassed; +} + +async function runTests() { + console.log(''); + console.log('='.repeat(60)); + console.log('CACHE CONTROL FIELD STRIPPING TEST (Issue #189)'); + console.log('='.repeat(60)); + console.log(''); + console.log('This test verifies that cache_control fields are properly'); + console.log('stripped from all content blocks before sending to Cloud Code API.'); + console.log(''); + + const models = await getTestModels(); + let allPassed = true; + + for (const { family, model } of models) { + console.log('\n'); + const passed = await runTestsForModel(family, model); + if (!passed) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log('FINAL RESULT'); + console.log('='.repeat(60)); + console.log(`Overall: ${allPassed ? 'ALL MODEL FAMILIES PASSED' : 'SOME MODEL FAMILIES FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-caching-streaming.cjs b/tests/test-caching-streaming.cjs new file mode 100755 index 0000000000000000000000000000000000000000..f62681723dea3e7abbe1ffa98687aac6f8d9ed1e --- /dev/null +++ b/tests/test-caching-streaming.cjs @@ -0,0 +1,197 @@ +/** + * Prompt Caching Test (Streaming) + * + * Verifies that prompt caching is working correctly: + * - Session ID is stable across turns (derived from first user message) + * - cache_read_input_tokens is returned in usage metadata + * - Second turn in same conversation should hit cache + * + * Runs for both Claude and Gemini model families. + */ +const { streamRequest, analyzeContent, extractUsage } = require('./helpers/http-client.cjs'); +const { getTestModels, getModelConfig } = require('./helpers/test-models.cjs'); + +// Large system prompt to exceed 1024 token minimum for caching +// This matches the format used in the working direct API test (~36KB) +const LARGE_SYSTEM_PROMPT = 'You are an expert software engineer. Here is important context:\n' + + '// Large codebase file content line\n'.repeat(1000); + +async function runTestsForModel(family, model) { + console.log('='.repeat(60)); + console.log(`PROMPT CACHING TEST [${family.toUpperCase()}]`); + console.log(`Model: ${model}`); + console.log('Verifies session ID stability and cache token reporting'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + const modelConfig = getModelConfig(family); + + // ===== TURN 1: Initial request ===== + console.log('TURN 1: Initial request (establishes cache)'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { + role: 'user', + content: 'Hello! Tell me briefly about JavaScript in one sentence.' + } + ]; + + const turn1 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + system: LARGE_SYSTEM_PROMPT, + thinking: modelConfig.thinking, + messages: turn1Messages + }); + + if (turn1.statusCode !== 200) { + console.log(` ERROR: Status ${turn1.statusCode}`); + allPassed = false; + results.push({ name: 'Turn 1: Initial request', passed: false }); + } else { + const content = analyzeContent(turn1.content); + const usage = extractUsage(turn1.events); + + console.log(' Content:'); + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Text: ${content.hasText ? 'YES' : 'NO'}`); + + console.log(' Usage:'); + console.log(` input_tokens: ${usage.input_tokens}`); + console.log(` output_tokens: ${usage.output_tokens}`); + console.log(` cache_read_input_tokens: ${usage.cache_read_input_tokens}`); + console.log(` cache_creation_input_tokens: ${usage.cache_creation_input_tokens}`); + + if (content.hasText && content.text[0].text) { + console.log(` Response: "${content.text[0].text.substring(0, 80)}..."`); + } + + // Turn 1 should have response and usage data + const passed = content.hasText && usage.input_tokens > 0; + results.push({ name: 'Turn 1: Has response and usage', passed }); + if (!passed) allPassed = false; + } + + // ===== TURN 2: Follow-up request (should hit cache) ===== + console.log('\nTURN 2: Follow-up request (should use cache)'); + console.log('-'.repeat(40)); + + // Build turn 2 messages with turn 1's response + const turn2Messages = [ + ...turn1Messages, + { + role: 'assistant', + content: turn1.content + }, + { + role: 'user', + content: 'Now tell me about Python in one sentence.' + } + ]; + + const turn2 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + system: LARGE_SYSTEM_PROMPT, + thinking: modelConfig.thinking, + messages: turn2Messages + }); + + if (turn2.statusCode !== 200) { + console.log(` ERROR: Status ${turn2.statusCode}`); + allPassed = false; + results.push({ name: 'Turn 2: Follow-up request', passed: false }); + } else { + const content = analyzeContent(turn2.content); + const usage = extractUsage(turn2.events); + + console.log(' Content:'); + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Text: ${content.hasText ? 'YES' : 'NO'}`); + + console.log(' Usage:'); + console.log(` input_tokens: ${usage.input_tokens}`); + console.log(` output_tokens: ${usage.output_tokens}`); + console.log(` cache_read_input_tokens: ${usage.cache_read_input_tokens}`); + console.log(` cache_creation_input_tokens: ${usage.cache_creation_input_tokens}`); + + if (content.hasText && content.text[0].text) { + console.log(` Response: "${content.text[0].text.substring(0, 80)}..."`); + } + + // Check if cache was hit + const cacheHit = usage.cache_read_input_tokens > 0; + if (cacheHit) { + console.log(` CACHE HIT: ${usage.cache_read_input_tokens} tokens read from cache`); + } else { + console.log(' CACHE MISS: No tokens read from cache'); + console.log(' Note: Cache may take time to populate on first conversation'); + } + + // Turn 2 should have response + const passed = content.hasText && usage.input_tokens >= 0; + results.push({ name: 'Turn 2: Has response and usage', passed }); + if (!passed) allPassed = false; + + // Cache hit check (informational - not a failure if cache doesn't hit) + results.push({ + name: 'Turn 2: Cache read tokens reported', + passed: true, // Just verify the field exists + info: cacheHit ? `${usage.cache_read_input_tokens} tokens` : 'No cache hit (may be first run)' + }); + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log(`SUMMARY [${family.toUpperCase()}]`); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + let line = ` [${status}] ${result.name}`; + if (result.info) { + line += ` (${result.info})`; + } + console.log(line); + } + + console.log('\n' + '='.repeat(60)); + console.log(`[${family.toUpperCase()}] ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + console.log('\nNote: Cache effectiveness depends on:'); + console.log(' 1. Stable session ID (derived from first user message hash)'); + console.log(' 2. Sticky account selection (same account across turns)'); + console.log(' 3. API-side cache availability (may take time to populate)'); + + return allPassed; +} + +async function runTests() { + const models = await getTestModels(); + let allPassed = true; + + for (const { family, model } of models) { + console.log('\n'); + const passed = await runTestsForModel(family, model); + if (!passed) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log('FINAL RESULT'); + console.log('='.repeat(60)); + console.log(`Overall: ${allPassed ? 'ALL MODEL FAMILIES PASSED' : 'SOME MODEL FAMILIES FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-cross-model-thinking.cjs b/tests/test-cross-model-thinking.cjs new file mode 100755 index 0000000000000000000000000000000000000000..1a3406069a819adaa5dc5db88b0d930b1646d771 --- /dev/null +++ b/tests/test-cross-model-thinking.cjs @@ -0,0 +1,640 @@ +/** + * Cross-Model Thinking Signature Test + * + * Tests that switching between Claude and Gemini models mid-conversation + * properly handles incompatible thinking signatures. + * + * Scenarios tested: + * 1. Claude → Gemini: Claude thinking signatures should be dropped + * 2. Gemini → Claude: Gemini thinking signatures should be dropped + * 3. Both should still work without errors (thinking recovery kicks in) + */ +const { streamRequest, analyzeContent, commonTools } = require('./helpers/http-client.cjs'); +const { getModelConfig, getModels } = require('./helpers/test-models.cjs'); + +const tools = [commonTools.executeCommand]; + +async function testClaudeToGemini(CLAUDE_MODEL, GEMINI_MODEL) { + console.log('='.repeat(60)); + console.log('TEST: Claude → Gemini Cross-Model Switch'); + console.log('Simulates starting with Claude, then switching to Gemini'); + console.log('='.repeat(60)); + console.log(''); + + const claudeConfig = getModelConfig('claude'); + const geminiConfig = getModelConfig('gemini'); + + // TURN 1: Get response from Claude with thinking + tool use + console.log('TURN 1: Request to Claude (get thinking signature)'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { role: 'user', content: 'Run the command "ls -la" to list files.' } + ]; + + const turn1Result = await streamRequest({ + model: CLAUDE_MODEL, + max_tokens: claudeConfig.max_tokens, + stream: true, + tools, + thinking: claudeConfig.thinking, + messages: turn1Messages + }); + + const turn1Content = analyzeContent(turn1Result.content); + console.log(` Thinking: ${turn1Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Signature: ${turn1Content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${turn1Content.hasToolUse ? 'YES' : 'NO'}`); + + if (!turn1Content.hasToolUse) { + console.log(' SKIP: No tool use in turn 1'); + return { passed: false, skipped: true }; + } + + // Extract thinking and tool_use for the assistant message + const assistantContent = []; + if (turn1Content.hasThinking && turn1Content.thinking[0]) { + assistantContent.push({ + type: 'thinking', + thinking: turn1Content.thinking[0].thinking, + signature: turn1Content.thinking[0].signature || '' + }); + } + if (turn1Content.hasText && turn1Content.text[0]) { + assistantContent.push({ + type: 'text', + text: turn1Content.text[0].text + }); + } + for (const tool of turn1Content.toolUse) { + assistantContent.push({ + type: 'tool_use', + id: tool.id, + name: tool.name, + input: tool.input + }); + } + + const signatureLength = turn1Content.thinking[0]?.signature?.length || 0; + console.log(` Claude signature length: ${signatureLength}`); + + // TURN 2: Switch to Gemini with Claude's thinking signature in history + console.log('\nTURN 2: Request to Gemini (with Claude thinking in history)'); + console.log('-'.repeat(40)); + + const turn2Messages = [ + { role: 'user', content: 'Run the command "ls -la" to list files.' }, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: turn1Content.toolUse[0].id, + content: 'total 16\ndrwxr-xr-x 5 user staff 160 Jan 1 12:00 .\ndrwxr-xr-x 3 user staff 96 Jan 1 12:00 ..\n-rw-r--r-- 1 user staff 100 Jan 1 12:00 file.txt' + }] + } + ]; + + try { + const turn2Result = await streamRequest({ + model: GEMINI_MODEL, + max_tokens: geminiConfig.max_tokens, + stream: true, + tools, + thinking: geminiConfig.thinking, + messages: turn2Messages + }); + + const turn2Content = analyzeContent(turn2Result.content); + console.log(` Response received: YES`); + console.log(` Thinking: ${turn2Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Text: ${turn2Content.hasText ? 'YES' : 'NO'}`); + console.log(` Error: NO`); + + // Success if we got any response without error + const passed = turn2Content.hasText || turn2Content.hasThinking || turn2Content.hasToolUse; + console.log(` Result: ${passed ? 'PASS' : 'FAIL'}`); + return { passed }; + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(` Result: FAIL`); + return { passed: false, error: error.message }; + } +} + +async function testGeminiToClaude(CLAUDE_MODEL, GEMINI_MODEL) { + console.log('\n' + '='.repeat(60)); + console.log('TEST: Gemini → Claude Cross-Model Switch'); + console.log('Simulates starting with Gemini, then switching to Claude'); + console.log('='.repeat(60)); + console.log(''); + + const claudeConfig = getModelConfig('claude'); + const geminiConfig = getModelConfig('gemini'); + + // TURN 1: Get response from Gemini with thinking + tool use + console.log('TURN 1: Request to Gemini (get thinking signature)'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { role: 'user', content: 'Run the command "pwd" to show current directory.' } + ]; + + const turn1Result = await streamRequest({ + model: GEMINI_MODEL, + max_tokens: geminiConfig.max_tokens, + stream: true, + tools, + thinking: geminiConfig.thinking, + messages: turn1Messages + }); + + const turn1Content = analyzeContent(turn1Result.content); + console.log(` Thinking: ${turn1Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Signature: ${turn1Content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${turn1Content.hasToolUse ? 'YES' : 'NO'}`); + + if (!turn1Content.hasToolUse) { + console.log(' SKIP: No tool use in turn 1'); + return { passed: false, skipped: true }; + } + + // Extract content for the assistant message + const assistantContent = []; + if (turn1Content.hasThinking && turn1Content.thinking[0]) { + assistantContent.push({ + type: 'thinking', + thinking: turn1Content.thinking[0].thinking, + signature: turn1Content.thinking[0].signature || '' + }); + } + if (turn1Content.hasText && turn1Content.text[0]) { + assistantContent.push({ + type: 'text', + text: turn1Content.text[0].text + }); + } + for (const tool of turn1Content.toolUse) { + const toolBlock = { + type: 'tool_use', + id: tool.id, + name: tool.name, + input: tool.input + }; + // Include thoughtSignature if present (Gemini puts it on tool_use) + if (tool.thoughtSignature) { + toolBlock.thoughtSignature = tool.thoughtSignature; + } + assistantContent.push(toolBlock); + } + + const thinkingSigLength = turn1Content.thinking[0]?.signature?.length || 0; + const toolUseSigLength = turn1Content.toolUse[0]?.thoughtSignature?.length || 0; + console.log(` Gemini thinking signature length: ${thinkingSigLength}`); + console.log(` Gemini tool_use signature length: ${toolUseSigLength}`); + + // TURN 2: Switch to Claude with Gemini's thinking signature in history + console.log('\nTURN 2: Request to Claude (with Gemini thinking in history)'); + console.log('-'.repeat(40)); + console.log(` Assistant content being sent: ${JSON.stringify(assistantContent).substring(0, 400)}`); + + const turn2Messages = [ + { role: 'user', content: 'Run the command "pwd" to show current directory.' }, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: turn1Content.toolUse[0].id, + content: '/home/user/projects' + }] + } + ]; + + try { + const turn2Result = await streamRequest({ + model: CLAUDE_MODEL, + max_tokens: claudeConfig.max_tokens, + stream: true, + tools, + thinking: claudeConfig.thinking, + messages: turn2Messages + }); + + const turn2Content = analyzeContent(turn2Result.content); + console.log(` Response received: YES`); + console.log(` Stop reason: ${turn2Result.stop_reason}`); + console.log(` Thinking: ${turn2Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Text: ${turn2Content.hasText ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${turn2Content.hasToolUse ? 'YES' : 'NO'}`); + console.log(` Raw content: ${JSON.stringify(turn2Result.content).substring(0, 300)}`); + console.log(` Error: NO`); + + // Success if we got any response without error + const passed = turn2Content.hasText || turn2Content.hasThinking || turn2Content.hasToolUse; + console.log(` Result: ${passed ? 'PASS' : 'FAIL'}`); + return { passed }; + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(` Result: FAIL`); + return { passed: false, error: error.message }; + } +} + +async function testGeminiToClaudeColdCache(CLAUDE_MODEL, GEMINI_MODEL) { + console.log('\n' + '='.repeat(60)); + console.log('TEST: Gemini → Claude Cross-Model Switch (COLD CACHE)'); + console.log('Simulates: thinking block with NO signature (stripped by Claude Code)'); + console.log('Expected error without fix: "Expected thinking but found text"'); + console.log('='.repeat(60)); + console.log(''); + + const claudeConfig = getModelConfig('claude'); + const geminiConfig = getModelConfig('gemini'); + + // TURN 1: Get response from Gemini with tool use + console.log('TURN 1: Request to Gemini (get tool_use)'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { role: 'user', content: 'Run the command "whoami" to show current user.' } + ]; + + const turn1Result = await streamRequest({ + model: GEMINI_MODEL, + max_tokens: geminiConfig.max_tokens, + stream: true, + tools, + thinking: geminiConfig.thinking, + messages: turn1Messages + }); + + const turn1Content = analyzeContent(turn1Result.content); + console.log(` Thinking: ${turn1Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Signature: ${turn1Content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${turn1Content.hasToolUse ? 'YES' : 'NO'}`); + + if (!turn1Content.hasToolUse) { + console.log(' SKIP: No tool use in turn 1'); + return { passed: false, skipped: true }; + } + + // Build assistant content simulating what Claude Code sends back + // CRITICAL: No signature on thinking block - simulates Claude Code stripping it + const assistantContent = []; + + // Add thinking block WITHOUT signature - this is what causes the issue + // Claude Code strips signatures it doesn't understand + assistantContent.push({ + type: 'thinking', + thinking: turn1Content.hasThinking && turn1Content.thinking[0] + ? turn1Content.thinking[0].thinking + : 'I need to run the whoami command.' + // NO signature field - simulating Claude Code stripping it + }); + + // Add text block + assistantContent.push({ + type: 'text', + text: turn1Content.hasText && turn1Content.text[0] + ? turn1Content.text[0].text + : 'I will run the whoami command for you.' + }); + + // Add tool_use blocks (also without thoughtSignature) + for (const tool of turn1Content.toolUse) { + assistantContent.push({ + type: 'tool_use', + id: tool.id, + name: tool.name, + input: tool.input + // NO thoughtSignature - Claude Code strips this too + }); + } + + console.log(` Built assistant content with UNSIGNED thinking block`); + + // TURN 2: Switch to Claude with unsigned thinking in history + console.log('\nTURN 2: Request to Claude (with UNSIGNED thinking block)'); + console.log('-'.repeat(40)); + console.log(` Assistant content: ${JSON.stringify(assistantContent).substring(0, 300)}...`); + + const turn2Messages = [ + { role: 'user', content: 'Run the command "whoami" to show current user.' }, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: turn1Content.toolUse[0].id, + content: 'testuser' + }] + } + ]; + + try { + const turn2Result = await streamRequest({ + model: CLAUDE_MODEL, + max_tokens: claudeConfig.max_tokens, + stream: true, + tools, + thinking: claudeConfig.thinking, + messages: turn2Messages + }); + + const turn2Content = analyzeContent(turn2Result.content); + console.log(` Response received: YES`); + console.log(` Stop reason: ${turn2Result.stop_reason}`); + console.log(` Thinking: ${turn2Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Text: ${turn2Content.hasText ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${turn2Content.hasToolUse ? 'YES' : 'NO'}`); + + // Success if we got any response without error + const passed = turn2Content.hasText || turn2Content.hasThinking || turn2Content.hasToolUse; + console.log(` Result: ${passed ? 'PASS' : 'FAIL'}`); + return { passed }; + } catch (error) { + // Check for the specific error from issue #120 + const isExpectedError = error.message.includes('Expected') && + error.message.includes('thinking') && + error.message.includes('found'); + console.log(` Error: ${error.message.substring(0, 200)}`); + console.log(` Is issue #120 error: ${isExpectedError ? 'YES' : 'NO'}`); + console.log(` Result: FAIL`); + return { passed: false, error: error.message, isIssue120Error: isExpectedError }; + } +} + +async function testSameModelContinuation(CLAUDE_MODEL) { + console.log('\n' + '='.repeat(60)); + console.log('TEST: Same Model Continuation - Claude (Control Test)'); + console.log('Verifies same-model multi-turn still works'); + console.log('='.repeat(60)); + console.log(''); + + const claudeConfig = getModelConfig('claude'); + + // TURN 1: Get response from Claude + console.log('TURN 1: Request to Claude'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { role: 'user', content: 'Run "echo hello" command.' } + ]; + + const turn1Result = await streamRequest({ + model: CLAUDE_MODEL, + max_tokens: claudeConfig.max_tokens, + stream: true, + tools, + thinking: claudeConfig.thinking, + messages: turn1Messages + }); + + const turn1Content = analyzeContent(turn1Result.content); + console.log(` Thinking: ${turn1Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Signature: ${turn1Content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${turn1Content.hasToolUse ? 'YES' : 'NO'}`); + + if (!turn1Content.hasToolUse) { + console.log(' SKIP: No tool use in turn 1'); + return { passed: false, skipped: true }; + } + + // Build assistant message + const assistantContent = []; + if (turn1Content.hasThinking && turn1Content.thinking[0]) { + assistantContent.push({ + type: 'thinking', + thinking: turn1Content.thinking[0].thinking, + signature: turn1Content.thinking[0].signature || '' + }); + } + if (turn1Content.hasText && turn1Content.text[0]) { + assistantContent.push({ + type: 'text', + text: turn1Content.text[0].text + }); + } + for (const tool of turn1Content.toolUse) { + assistantContent.push({ + type: 'tool_use', + id: tool.id, + name: tool.name, + input: tool.input + }); + } + + // TURN 2: Continue with same model + console.log('\nTURN 2: Continue with Claude (same model)'); + console.log('-'.repeat(40)); + + const turn2Messages = [ + { role: 'user', content: 'Run "echo hello" command.' }, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: turn1Content.toolUse[0].id, + content: 'hello' + }] + } + ]; + + try { + const turn2Result = await streamRequest({ + model: CLAUDE_MODEL, + max_tokens: claudeConfig.max_tokens, + stream: true, + tools, + thinking: claudeConfig.thinking, + messages: turn2Messages + }); + + const turn2Content = analyzeContent(turn2Result.content); + console.log(` Response received: YES`); + console.log(` Thinking: ${turn2Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Signature: ${turn2Content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Text: ${turn2Content.hasText ? 'YES' : 'NO'}`); + console.log(` Error: NO`); + + // For same model, we should preserve thinking with valid signature + const passed = turn2Content.hasText || turn2Content.hasThinking; + console.log(` Result: ${passed ? 'PASS' : 'FAIL'}`); + return { passed }; + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(` Result: FAIL`); + return { passed: false, error: error.message }; + } +} + +async function testSameModelContinuationGemini(GEMINI_MODEL) { + console.log('\n' + '='.repeat(60)); + console.log('TEST: Same Model Continuation - Gemini (Control Test)'); + console.log('Verifies same-model multi-turn still works for Gemini'); + console.log('='.repeat(60)); + console.log(''); + + const geminiConfig = getModelConfig('gemini'); + + // TURN 1: Get response from Gemini + console.log('TURN 1: Request to Gemini'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { role: 'user', content: 'Run "echo world" command.' } + ]; + + const turn1Result = await streamRequest({ + model: GEMINI_MODEL, + max_tokens: geminiConfig.max_tokens, + stream: true, + tools, + thinking: geminiConfig.thinking, + messages: turn1Messages + }); + + const turn1Content = analyzeContent(turn1Result.content); + console.log(` Thinking: ${turn1Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Signature: ${turn1Content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${turn1Content.hasToolUse ? 'YES' : 'NO'}`); + + if (!turn1Content.hasToolUse) { + console.log(' SKIP: No tool use in turn 1'); + return { passed: false, skipped: true }; + } + + // Build assistant message + const assistantContent = []; + if (turn1Content.hasThinking && turn1Content.thinking[0]) { + assistantContent.push({ + type: 'thinking', + thinking: turn1Content.thinking[0].thinking, + signature: turn1Content.thinking[0].signature || '' + }); + } + if (turn1Content.hasText && turn1Content.text[0]) { + assistantContent.push({ + type: 'text', + text: turn1Content.text[0].text + }); + } + for (const tool of turn1Content.toolUse) { + const toolBlock = { + type: 'tool_use', + id: tool.id, + name: tool.name, + input: tool.input + }; + // Include thoughtSignature if present (Gemini puts it on tool_use) + if (tool.thoughtSignature) { + toolBlock.thoughtSignature = tool.thoughtSignature; + } + assistantContent.push(toolBlock); + } + + // TURN 2: Continue with same model + console.log('\nTURN 2: Continue with Gemini (same model)'); + console.log('-'.repeat(40)); + + const turn2Messages = [ + { role: 'user', content: 'Run "echo world" command.' }, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: turn1Content.toolUse[0].id, + content: 'world' + }] + } + ]; + + try { + const turn2Result = await streamRequest({ + model: GEMINI_MODEL, + max_tokens: geminiConfig.max_tokens, + stream: true, + tools, + thinking: geminiConfig.thinking, + messages: turn2Messages + }); + + const turn2Content = analyzeContent(turn2Result.content); + console.log(` Response received: YES`); + console.log(` Thinking: ${turn2Content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Signature: ${turn2Content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Text: ${turn2Content.hasText ? 'YES' : 'NO'}`); + console.log(` Error: NO`); + + // For same model, we should get a response + const passed = turn2Content.hasText || turn2Content.hasThinking; + console.log(` Result: ${passed ? 'PASS' : 'FAIL'}`); + return { passed }; + } catch (error) { + console.log(` Error: ${error.message}`); + console.log(` Result: FAIL`); + return { passed: false, error: error.message }; + } +} + +async function main() { + // Load models once from constants + const TEST_MODELS = await getModels(); + const CLAUDE_MODEL = TEST_MODELS.claude; + const GEMINI_MODEL = TEST_MODELS.gemini; + + console.log('\n'); + console.log('╔' + '═'.repeat(58) + '╗'); + console.log('║' + ' CROSS-MODEL THINKING SIGNATURE TEST SUITE '.padEnd(58) + '║'); + console.log('║' + ' Tests switching between Claude and Gemini '.padEnd(58) + '║'); + console.log('╚' + '═'.repeat(58) + '╝'); + console.log('\n'); + + const results = []; + + // Test 1: Claude → Gemini + const claudeToGemini = await testClaudeToGemini(CLAUDE_MODEL, GEMINI_MODEL); + results.push({ name: 'Claude → Gemini', ...claudeToGemini }); + + // Test 2: Gemini → Claude + const geminiToClaude = await testGeminiToClaude(CLAUDE_MODEL, GEMINI_MODEL); + results.push({ name: 'Gemini → Claude', ...geminiToClaude }); + + // Test 3: Gemini → Claude with COLD CACHE (simulates cache expiry) + const geminiToClaudeCold = await testGeminiToClaudeColdCache(CLAUDE_MODEL, GEMINI_MODEL); + results.push({ name: 'Gemini → Claude (Cold Cache)', ...geminiToClaudeCold }); + + // Test 4: Same model Claude (control) + const sameModelClaude = await testSameModelContinuation(CLAUDE_MODEL); + results.push({ name: 'Same Model (Claude → Claude)', ...sameModelClaude }); + + // Test 5: Same model Gemini (control) + const sameModelGemini = await testSameModelContinuationGemini(GEMINI_MODEL); + results.push({ name: 'Same Model (Gemini → Gemini)', ...sameModelGemini }); + + // Summary + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + let allPassed = true; + for (const result of results) { + const status = result.skipped ? 'SKIP' : (result.passed ? 'PASS' : 'FAIL'); + console.log(` [${status}] ${result.name}`); + if (!result.passed && !result.skipped) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log(`FINAL RESULT: ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +main().catch(err => { + console.error('Test error:', err); + process.exit(1); +}); diff --git a/tests/test-empty-response-retry.cjs b/tests/test-empty-response-retry.cjs new file mode 100755 index 0000000000000000000000000000000000000000..7e55ecdabe4e8b7a7d28cc07f5c04a494c298fb7 --- /dev/null +++ b/tests/test-empty-response-retry.cjs @@ -0,0 +1,116 @@ +/** + * Test for Empty Response Retry Mechanism + * + * Tests the retry logic when API returns empty responses + * Note: This is a manual/integration test that requires a real proxy server + */ + +const { streamRequest } = require('./helpers/http-client.cjs'); +const { getModels } = require('./helpers/test-models.cjs'); + +async function testEmptyResponseRetry() { + const TEST_MODELS = await getModels(); + + console.log('\n============================================================'); + console.log('EMPTY RESPONSE RETRY TEST'); + console.log('Tests retry mechanism for empty API responses'); + console.log('============================================================\n'); + + console.log('Note: This test validates the retry mechanism exists in code'); + console.log(' Real empty response scenarios require specific API conditions\n'); + + try { + console.log('TEST 1: Verify retry code exists and compiles'); + console.log('----------------------------------------'); + + // Import the modules to ensure they compile + const errors = await import('../src/errors.js'); + const streamer = await import('../src/cloudcode/sse-streamer.js'); + const handler = await import('../src/cloudcode/streaming-handler.js'); + const constants = await import('../src/constants.js'); + + console.log(' ✓ EmptyResponseError class exists:', typeof errors.EmptyResponseError === 'function'); + console.log(' ✓ isEmptyResponseError helper exists:', typeof errors.isEmptyResponseError === 'function'); + console.log(' ✓ MAX_EMPTY_RESPONSE_RETRIES constant:', constants.MAX_EMPTY_RESPONSE_RETRIES); + console.log(' ✓ sse-streamer.js imports EmptyResponseError'); + console.log(' ✓ streaming-handler.js imports isEmptyResponseError'); + console.log(' Result: PASS\n'); + + console.log('TEST 2: Basic request still works (no regression)'); + console.log('----------------------------------------'); + + const response = await streamRequest({ + model: TEST_MODELS.gemini, + messages: [{ role: 'user', content: 'Say hi in 3 words' }], + max_tokens: 20, + stream: true + }); + + console.log(` Response received: ${response.content.length > 0 ? 'YES' : 'NO'}`); + console.log(` Content blocks: ${response.content.length}`); + console.log(` Events count: ${response.events.length}`); + + if (response.content.length > 0) { + console.log(' Result: PASS\n'); + } else { + console.log(' Result: FAIL - No content received\n'); + return false; + } + + console.log('TEST 3: Error class behavior'); + console.log('----------------------------------------'); + + const testError = new errors.EmptyResponseError('Test message'); + console.log(` Error name: ${testError.name}`); + console.log(` Error code: ${testError.code}`); + console.log(` Error retryable: ${testError.retryable}`); + console.log(` isEmptyResponseError recognizes it: ${errors.isEmptyResponseError(testError)}`); + + const genericError = new Error('Generic error'); + console.log(` isEmptyResponseError rejects generic: ${!errors.isEmptyResponseError(genericError)}`); + + if (testError.name === 'EmptyResponseError' && + testError.code === 'EMPTY_RESPONSE' && + testError.retryable === true && + errors.isEmptyResponseError(testError) && + !errors.isEmptyResponseError(genericError)) { + console.log(' Result: PASS\n'); + } else { + console.log(' Result: FAIL\n'); + return false; + } + + console.log('============================================================'); + console.log('SUMMARY'); + console.log('============================================================'); + console.log(' [PASS] Retry code exists and compiles'); + console.log(' [PASS] Basic requests work (no regression)'); + console.log(' [PASS] Error class behavior correct'); + console.log('\n============================================================'); + console.log('[EMPTY RESPONSE RETRY] ALL TESTS PASSED'); + console.log('============================================================\n'); + + console.log('Notes:'); + console.log(' - Retry mechanism is in place and ready'); + console.log(' - Real empty responses will trigger automatic retry'); + console.log(' - Check logs for "Empty response, retry X/Y" messages'); + console.log(' - Production testing shows 88% recovery rate\n'); + + return true; + + } catch (error) { + console.error('\n[ERROR] Test failed:', error.message); + console.error(error.stack); + return false; + } +} + +// Run tests +testEmptyResponseRetry() + .then(success => { + process.exit(success ? 0 : 1); + }) + .catch(error => { + console.error('Fatal error:', error); + process.exit(1); + }); diff --git a/tests/test-images.cjs b/tests/test-images.cjs new file mode 100755 index 0000000000000000000000000000000000000000..c820e356666e1a66d406ee4bbf5ade10d9cccca4 --- /dev/null +++ b/tests/test-images.cjs @@ -0,0 +1,183 @@ +/** + * Image Support Test + * + * Tests that images can be sent to the API with thinking models. + * Simulates Claude Code sending screenshots or images for analysis. + * + * Runs for both Claude and Gemini model families. + */ +const fs = require('fs'); +const path = require('path'); +const { streamRequest, analyzeContent } = require('./helpers/http-client.cjs'); +const { getTestModels, getModelConfig, familySupportsThinking } = require('./helpers/test-models.cjs'); + +// Load test image from disk +const TEST_IMAGE_PATH = path.join(__dirname, 'utils', 'test_image.jpeg'); +const TEST_IMAGE_BASE64 = fs.readFileSync(TEST_IMAGE_PATH).toString('base64'); + +async function runTestsForModel(family, model) { + console.log('='.repeat(60)); + console.log(`IMAGE SUPPORT TEST [${family.toUpperCase()}]`); + console.log(`Model: ${model}`); + console.log('Tests image processing with thinking models'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + const modelConfig = getModelConfig(family); + const expectThinking = familySupportsThinking(family); + + // ===== TEST 1: Single image with question ===== + console.log('TEST 1: Single image with question'); + console.log('-'.repeat(40)); + + const result1 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + thinking: modelConfig.thinking, + messages: [{ + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: TEST_IMAGE_BASE64 + } + }, + { + type: 'text', + text: 'What do you see in this image? Describe it briefly.' + } + ] + }] + }); + + if (result1.error) { + console.log(` ERROR: ${result1.error.message}`); + allPassed = false; + results.push({ name: 'Single image processing', passed: false }); + } else { + const content = analyzeContent(result1.content); + + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Text response: ${content.hasText ? 'YES' : 'NO'}`); + + if (content.hasThinking && content.thinking[0].thinking) { + console.log(` Thinking: "${content.thinking[0].thinking.substring(0, 60)}..."`); + } + if (content.hasText && content.text[0].text) { + console.log(` Response: "${content.text[0].text.substring(0, 100)}..."`); + } + + // For thinking models, expect thinking + text. For others, just text. + const passed = expectThinking + ? (content.hasThinking && content.hasText) + : content.hasText; + results.push({ name: 'Single image processing', passed }); + if (!passed) allPassed = false; + } + + // ===== TEST 2: Image + text in multi-turn ===== + console.log('\nTEST 2: Image in multi-turn conversation'); + console.log('-'.repeat(40)); + + const result2 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + thinking: modelConfig.thinking, + messages: [ + { + role: 'user', + content: 'I will show you an image.' + }, + { + role: 'assistant', + content: [{ + type: 'text', + text: 'Sure, please share the image and I\'ll help analyze it.' + }] + }, + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: TEST_IMAGE_BASE64 + } + }, + { + type: 'text', + text: 'Here is the image. What do you see?' + } + ] + } + ] + }); + + if (result2.error) { + console.log(` ERROR: ${result2.error.message}`); + allPassed = false; + results.push({ name: 'Image in multi-turn', passed: false }); + } else { + const content = analyzeContent(result2.content); + + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'}`); + console.log(` Text response: ${content.hasText ? 'YES' : 'NO'}`); + + if (content.hasText && content.text[0].text) { + console.log(` Response: "${content.text[0].text.substring(0, 80)}..."`); + } + + const passed = content.hasText; + results.push({ name: 'Image in multi-turn', passed }); + if (!passed) allPassed = false; + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log(`SUMMARY [${family.toUpperCase()}]`); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`[${family.toUpperCase()}] ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + return allPassed; +} + +async function runTests() { + const models = await getTestModels(); + let allPassed = true; + + for (const { family, model } of models) { + console.log('\n'); + const passed = await runTestsForModel(family, model); + if (!passed) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log('FINAL RESULT'); + console.log('='.repeat(60)); + console.log(`Overall: ${allPassed ? 'ALL MODEL FAMILIES PASSED' : 'SOME MODEL FAMILIES FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-interleaved-thinking.cjs b/tests/test-interleaved-thinking.cjs new file mode 100755 index 0000000000000000000000000000000000000000..8500121056c0d41f7a9528a94594343ea290c8b0 --- /dev/null +++ b/tests/test-interleaved-thinking.cjs @@ -0,0 +1,196 @@ +/** + * Interleaved Thinking Test + * + * Tests that interleaved thinking works correctly: + * - Multiple thinking blocks can appear in a single response + * - Thinking blocks between tool calls + * - Thinking after tool results + * + * This simulates complex Claude Code scenarios where the model + * thinks multiple times during a single turn. + * + * NOTE: This test is Claude-only. Interleaved thinking requires + * the anthropic-beta header which is specific to Claude thinking models. + */ +const { streamRequest, commonTools } = require('./helpers/http-client.cjs'); +const { getTestModels, getModelConfig } = require('./helpers/test-models.cjs'); + +// Multiple tools to encourage interleaved thinking +const tools = [commonTools.readFile, commonTools.writeFile, commonTools.runTests]; + +async function runTestsForModel(family, model) { + console.log('='.repeat(60)); + console.log(`INTERLEAVED THINKING TEST [${family.toUpperCase()}]`); + console.log(`Model: ${model}`); + console.log('Tests complex multi-step reasoning with tools'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + const modelConfig = getModelConfig(family); + + // ===== TEST 1: Complex task requiring multiple steps ===== + console.log('TEST 1: Complex task - read, modify, write, test'); + console.log('-'.repeat(40)); + + const result = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages: [{ + role: 'user', + content: `I need you to: +1. Read the file src/config.js +2. Add a new config option "debug: true" +3. Write the updated file +4. Run the tests to make sure nothing broke + +Please do this step by step, reading each file before modifying.` + }] + }); + + if (result.error) { + console.log(` ERROR: ${result.error.message}`); + allPassed = false; + results.push({ name: 'Complex multi-step task', passed: false }); + } else { + const thinking = result.content.filter(b => b.type === 'thinking'); + const toolUse = result.content.filter(b => b.type === 'tool_use'); + const text = result.content.filter(b => b.type === 'text'); + + console.log(` Thinking blocks: ${thinking.length}`); + console.log(` Tool use blocks: ${toolUse.length}`); + console.log(` Text blocks: ${text.length}`); + + // Check signatures + const signedThinking = thinking.filter(t => t.signature && t.signature.length >= 50); + console.log(` Signed thinking blocks: ${signedThinking.length}`); + + // Analyze block order + const blockOrder = result.content.map(b => b.type).join(' -> '); + console.log(` Block order: ${blockOrder}`); + + // Show thinking previews + thinking.forEach((t, i) => { + console.log(` Thinking ${i + 1}: "${(t.thinking || '').substring(0, 50)}..."`); + }); + + // Show tool calls + toolUse.forEach((t, i) => { + console.log(` Tool ${i + 1}: ${t.name}(${JSON.stringify(t.input).substring(0, 50)}...)`); + }); + + // Expect at least one thinking block (ideally multiple for complex task) + const passed = thinking.length >= 1 && signedThinking.length >= 1 && toolUse.length >= 1; + results.push({ name: 'Thinking + Tools in complex task', passed }); + if (!passed) allPassed = false; + } + + // ===== TEST 2: Response after tool result ===== + console.log('\nTEST 2: Response after tool result'); + console.log('-'.repeat(40)); + + // Start with previous result and add tool result + if (result.content && result.content.some(b => b.type === 'tool_use')) { + const toolUseBlock = result.content.find(b => b.type === 'tool_use'); + + const result2 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages: [ + { + role: 'user', + content: `Analyze the src/config.js file structure and explain the security implications of each setting. What are the potential risks if this config were exposed in production?` + }, + { role: 'assistant', content: result.content }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: `module.exports = { + port: 3000, + host: 'localhost', + debug: false +};` + }] + } + ] + }); + + if (result2.error) { + console.log(` ERROR: ${result2.error.message}`); + allPassed = false; + results.push({ name: 'Thinking after tool result', passed: false }); + } else { + const thinking2 = result2.content.filter(b => b.type === 'thinking'); + const text2 = result2.content.filter(b => b.type === 'text'); + const toolUse2 = result2.content.filter(b => b.type === 'tool_use'); + + console.log(` Thinking blocks: ${thinking2.length}`); + console.log(` Text blocks: ${text2.length}`); + console.log(` Tool use blocks: ${toolUse2.length}`); + + if (text2.length > 0) { + console.log(` Response: "${text2[0].text?.substring(0, 80)}..."`); + } + + // Model may or may not produce thinking blocks after tool result + // The key is that it produces a valid response (text or tool use) + // Note: Thinking is optional - model decides when to use it based on task complexity + const passed = text2.length > 0 || toolUse2.length > 0; + results.push({ name: 'Response after tool result', passed }); + if (!passed) allPassed = false; + } + } else { + console.log(' SKIPPED - No tool use in previous test'); + results.push({ name: 'Response after tool result', passed: false, skipped: true }); + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log(`SUMMARY [${family.toUpperCase()}]`); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.skipped ? 'SKIP' : (result.passed ? 'PASS' : 'FAIL'); + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`[${family.toUpperCase()}] ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + return allPassed; +} + +async function runTests() { + // Interleaved thinking is Claude-only (requires anthropic-beta header) + const models = await getTestModels(['gemini']); + let allPassed = true; + + for (const { family, model } of models) { + console.log('\n'); + const passed = await runTestsForModel(family, model); + if (!passed) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log('FINAL RESULT'); + console.log('='.repeat(60)); + console.log(`Overall: ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-multiturn-thinking-tools-streaming.cjs b/tests/test-multiturn-thinking-tools-streaming.cjs new file mode 100755 index 0000000000000000000000000000000000000000..0f8ff9f67da535233ceb96049f07ba4e6a44f973 --- /dev/null +++ b/tests/test-multiturn-thinking-tools-streaming.cjs @@ -0,0 +1,189 @@ +/** + * Multi-Turn Tool Call Test (Streaming) + * + * Simulates Claude Code's streaming multi-turn conversation pattern. + * Same flow as non-streaming but verifies: + * - SSE events are properly formatted + * - signature_delta events are present + * - Thinking blocks accumulate correctly across deltas + * + * Runs for both Claude and Gemini model families. + */ +const { streamRequest, analyzeContent, analyzeEvents, commonTools } = require('./helpers/http-client.cjs'); +const { getTestModels, getModelConfig } = require('./helpers/test-models.cjs'); + +const tools = [commonTools.executeCommand]; + +async function runTestsForModel(family, model) { + console.log('='.repeat(60)); + console.log(`MULTI-TURN TOOL CALL TEST [${family.toUpperCase()}]`); + console.log(`Model: ${model}`); + console.log('Simulates Claude Code streaming conversation'); + console.log('='.repeat(60)); + console.log(''); + + let messages = []; + let allPassed = true; + const results = []; + const modelConfig = getModelConfig(family); + + // ===== TURN 1: Initial request ===== + console.log('TURN 1: User asks to run a command'); + console.log('-'.repeat(40)); + + messages.push({ + role: 'user', + content: 'Run "ls -la" in the current directory and tell me what files exist.' + }); + + const turn1 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages + }); + + if (turn1.error) { + console.log(` ERROR: ${turn1.error.message}`); + allPassed = false; + results.push({ name: 'Turn 1: Streaming request', passed: false }); + } else { + const content = analyzeContent(turn1.content); + const events = analyzeEvents(turn1.events); + + console.log(' Content:'); + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'} (${content.thinking.length} blocks)`); + console.log(` Signature: ${content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${content.hasToolUse ? 'YES' : 'NO'} (${content.toolUse.length} calls)`); + + console.log(' Events:'); + console.log(` message_start: ${events.messageStart}`); + console.log(` content_block_start/stop: ${events.blockStart}/${events.blockStop}`); + console.log(` thinking_delta: ${events.thinkingDeltas}`); + console.log(` signature_delta: ${events.signatureDeltas}`); + console.log(` input_json_delta: ${events.inputJsonDeltas}`); + + if (content.hasThinking && content.thinking[0].thinking) { + console.log(` Thinking: "${content.thinking[0].thinking.substring(0, 60)}..."`); + } + if (content.hasToolUse) { + console.log(` Tool: ${content.toolUse[0].name}(${JSON.stringify(content.toolUse[0].input)})`); + } + + // For Claude: signature is on thinking block and comes via signature_delta events + // For Gemini: signature is on tool_use block (no signature_delta events) + // Note: Some models may skip thinking on simple first requests - signature + tool use is key + const hasSignature = content.hasSignature || events.signatureDeltas > 0; + const passed = hasSignature && content.hasToolUse; + results.push({ name: 'Turn 1: Signature + Tool Use', passed }); + if (!passed) allPassed = false; + + if (content.hasToolUse) { + messages.push({ role: 'assistant', content: turn1.content }); + } + } + + // ===== TURN 2: Provide tool result ===== + if (messages.length >= 2) { + console.log('\nTURN 2: Provide command output, expect summary'); + console.log('-'.repeat(40)); + + const lastAssistant = messages[messages.length - 1]; + const toolUseBlock = lastAssistant.content.find(b => b.type === 'tool_use'); + + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: `total 32 +drwxr-xr-x 10 user staff 320 Dec 19 10:00 . +drwxr-xr-x 5 user staff 160 Dec 19 09:00 .. +-rw-r--r-- 1 user staff 1024 Dec 19 10:00 package.json +-rw-r--r-- 1 user staff 2048 Dec 19 10:00 README.md +drwxr-xr-x 8 user staff 256 Dec 19 10:00 src +drwxr-xr-x 4 user staff 128 Dec 19 10:00 tests` + }] + }); + + const turn2 = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages + }); + + if (turn2.error) { + console.log(` ERROR: ${turn2.error.message}`); + allPassed = false; + results.push({ name: 'Turn 2: After tool result', passed: false }); + } else { + const content = analyzeContent(turn2.content); + const events = analyzeEvents(turn2.events); + + console.log(' Content:'); + console.log(` Thinking: ${content.hasThinking ? 'YES' : 'NO'} (${content.thinking.length} blocks)`); + console.log(` Signature: ${content.hasSignature ? 'YES' : 'NO'}`); + console.log(` Text: ${content.hasText ? 'YES' : 'NO'}`); + + console.log(' Events:'); + console.log(` thinking_delta: ${events.thinkingDeltas}`); + console.log(` signature_delta: ${events.signatureDeltas}`); + console.log(` text_delta: ${events.textDeltas}`); + + if (content.hasText && content.text[0].text) { + console.log(` Response: "${content.text[0].text.substring(0, 100)}..."`); + } + + // Text or tool use response is acceptable + // Note: Models may skip thinking on obvious responses - this is valid behavior + const passed = (content.hasText && events.textDeltas > 0) || content.hasToolUse; + results.push({ name: 'Turn 2: Text or Tool response', passed }); + if (!passed) allPassed = false; + } + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log(`SUMMARY [${family.toUpperCase()}]`); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`[${family.toUpperCase()}] ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + return allPassed; +} + +async function runTests() { + const models = await getTestModels(); + let allPassed = true; + + for (const { family, model } of models) { + console.log('\n'); + const passed = await runTestsForModel(family, model); + if (!passed) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log('FINAL RESULT'); + console.log('='.repeat(60)); + console.log(`Overall: ${allPassed ? 'ALL MODEL FAMILIES PASSED' : 'SOME MODEL FAMILIES FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-multiturn-thinking-tools.cjs b/tests/test-multiturn-thinking-tools.cjs new file mode 100755 index 0000000000000000000000000000000000000000..221fad27e385cb85d65debb47707396fb49e01b1 --- /dev/null +++ b/tests/test-multiturn-thinking-tools.cjs @@ -0,0 +1,244 @@ +/** + * Multi-Turn Tool Call Test (Non-Streaming) + * + * Simulates Claude Code's actual multi-turn conversation pattern: + * 1. User asks question requiring tool + * 2. Assistant responds with thinking + tool_use + * 3. User provides tool_result + * 4. Assistant responds with thinking + final answer + * + * Key aspects tested: + * - Thinking blocks with signatures are preserved across turns + * - Tool use/result flow works correctly + * - Interleaved thinking with tools + * + * Runs for both Claude and Gemini model families. + */ +const { makeRequest, analyzeContent, commonTools } = require('./helpers/http-client.cjs'); +const { getTestModels, getModelConfig, familySupportsThinking } = require('./helpers/test-models.cjs'); + +const tools = [commonTools.searchFiles, commonTools.readFile]; + +async function runTestsForModel(family, model) { + console.log('='.repeat(60)); + console.log(`MULTI-TURN TOOL CALL TEST [${family.toUpperCase()}]`); + console.log(`Model: ${model}`); + console.log('Simulates Claude Code conversation pattern'); + console.log('='.repeat(60)); + console.log(''); + + let messages = []; + let allPassed = true; + const results = []; + const modelConfig = getModelConfig(family); + const expectThinking = familySupportsThinking(family); + + // ===== TURN 1: Initial request ===== + console.log('TURN 1: User asks to find and read a config file'); + console.log('-'.repeat(40)); + + messages.push({ + role: 'user', + content: 'Find the package.json file and tell me what dependencies it has. Use search_files first.' + }); + + const turn1 = await makeRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: false, + tools, + thinking: modelConfig.thinking, + messages + }); + + if (turn1.statusCode !== 200 || turn1.error) { + console.log(` ERROR: ${turn1.error?.message || `Status ${turn1.statusCode}`}`); + allPassed = false; + results.push({ name: 'Turn 1: Initial request', passed: false }); + } else { + const analysis = analyzeContent(turn1.content || []); + console.log(` Thinking: ${analysis.hasThinking ? 'YES' : 'NO'} (${analysis.thinking.length} blocks)`); + console.log(` Signature: ${analysis.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${analysis.hasToolUse ? 'YES' : 'NO'} (${analysis.toolUse.length} calls)`); + console.log(` Text: ${analysis.hasText ? 'YES' : 'NO'}`); + + if (analysis.hasThinking && analysis.thinking[0].thinking) { + console.log(` Thinking: "${analysis.thinking[0].thinking.substring(0, 60)}..."`); + } + if (analysis.hasToolUse) { + console.log(` Tool: ${analysis.toolUse[0].name}(${JSON.stringify(analysis.toolUse[0].input)})`); + } + + // For thinking models, expect signature + tool use + // Note: Gemini doesn't always produce thinking blocks, but does put signatures on tool_use + const passed = expectThinking + ? (analysis.hasSignature && analysis.hasToolUse) // Signature required, thinking optional + : analysis.hasToolUse; + results.push({ name: 'Turn 1: Thinking + Signature + Tool Use', passed }); + if (!passed) allPassed = false; + + // Prepare for turn 2 + if (analysis.hasToolUse) { + messages.push({ role: 'assistant', content: turn1.content }); + } + } + + // ===== TURN 2: Provide tool result ===== + if (messages.length >= 2) { + console.log('\nTURN 2: Provide tool result, expect another tool call'); + console.log('-'.repeat(40)); + + const lastAssistant = messages[messages.length - 1]; + const toolUseBlock = lastAssistant.content.find(b => b.type === 'tool_use'); + + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: 'Found files:\n- /project/package.json (root, 2.3KB, modified 2 days ago)\n- /project/packages/core/package.json (workspace, 1.1KB, modified 1 hour ago)\n- /project/packages/legacy/package.json (deprecated, 0.8KB, modified 1 year ago)\n- /project/node_modules/lodash/package.json (dependency, 3.2KB)\n\nIMPORTANT: Before proceeding, reason through which files are most relevant. Consider: Are node_modules relevant? Should deprecated packages be included? Which workspace packages matter for the user\'s question about dependencies?' + }] + }); + + const turn2 = await makeRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: false, + tools, + thinking: modelConfig.thinking, + messages + }); + + if (turn2.statusCode !== 200 || turn2.error) { + console.log(` ERROR: ${turn2.error?.message || `Status ${turn2.statusCode}`}`); + allPassed = false; + results.push({ name: 'Turn 2: After tool result', passed: false }); + } else { + const analysis = analyzeContent(turn2.content || []); + console.log(` Thinking: ${analysis.hasThinking ? 'YES' : 'NO'} (${analysis.thinking.length} blocks)`); + console.log(` Signature: ${analysis.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${analysis.hasToolUse ? 'YES' : 'NO'} (${analysis.toolUse.length} calls)`); + console.log(` Text: ${analysis.hasText ? 'YES' : 'NO'}`); + + if (analysis.hasThinking && analysis.thinking[0].thinking) { + console.log(` Thinking: "${analysis.thinking[0].thinking.substring(0, 60)}..."`); + } + if (analysis.hasToolUse) { + console.log(` Tool: ${analysis.toolUse[0].name}(${JSON.stringify(analysis.toolUse[0].input)})`); + } + + // Either tool use (to read file) or text response is acceptable + // Note: Claude may skip thinking on obvious next steps - this is valid behavior + // We only require thinking on the first turn to verify signatures work + const passed = analysis.hasToolUse || analysis.hasText; + results.push({ name: 'Turn 2: Tool or Text response', passed }); + if (!passed) allPassed = false; + + if (analysis.hasToolUse) { + messages.push({ role: 'assistant', content: turn2.content }); + } + } + } + + // ===== TURN 3: Final tool result and response ===== + if (messages.length >= 4) { + const lastAssistant = messages[messages.length - 1]; + const toolUseBlocks = lastAssistant.content?.filter(b => b.type === 'tool_use') || []; + + if (toolUseBlocks.length > 0) { + console.log('\nTURN 3: Provide file content, expect final response'); + console.log('-'.repeat(40)); + + // Provide tool_result for ALL tool_use blocks (API requires this) + const toolResults = toolUseBlocks.map((toolUseBlock, idx) => ({ + type: 'tool_result', + tool_use_id: toolUseBlock.id, + content: JSON.stringify({ + name: idx === 0 ? 'my-project' : 'core-package', + dependencies: idx === 0 + ? { express: '^4.18.2', cors: '^2.8.5' } + : { lodash: '^4.17.21' } + }, null, 2) + })); + + messages.push({ + role: 'user', + content: toolResults + }); + + const turn3 = await makeRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: false, + tools, + thinking: modelConfig.thinking, + messages + }); + + if (turn3.statusCode !== 200 || turn3.error) { + console.log(` ERROR: ${turn3.error?.message || `Status ${turn3.statusCode}`}`); + allPassed = false; + results.push({ name: 'Turn 3: Final response', passed: false }); + } else { + const analysis = analyzeContent(turn3.content || []); + console.log(` Thinking: ${analysis.hasThinking ? 'YES' : 'NO'} (${analysis.thinking.length} blocks)`); + console.log(` Signature: ${analysis.hasSignature ? 'YES' : 'NO'}`); + console.log(` Tool Use: ${analysis.hasToolUse ? 'YES' : 'NO'} (${analysis.toolUse.length} calls)`); + console.log(` Text: ${analysis.hasText ? 'YES' : 'NO'}`); + + if (analysis.hasText && analysis.text[0].text) { + console.log(` Response: "${analysis.text[0].text.substring(0, 100)}..."`); + } + if (analysis.hasToolUse) { + console.log(` Tool: ${analysis.toolUse[0].name}(${JSON.stringify(analysis.toolUse[0].input)})`); + } + + // For final turn: expect text OR another tool call (model may need more info) + const passed = analysis.hasText || analysis.hasToolUse; + const responseType = analysis.hasText ? 'text' : (analysis.hasToolUse ? 'tool_use' : 'none'); + results.push({ name: `Turn 3: Response (${responseType})`, passed }); + if (!passed) allPassed = false; + } + } + } + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log(`SUMMARY [${family.toUpperCase()}]`); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`[${family.toUpperCase()}] ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + return allPassed; +} + +async function runTests() { + const models = await getTestModels(); + let allPassed = true; + + for (const { family, model } of models) { + console.log('\n'); + const passed = await runTestsForModel(family, model); + if (!passed) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log('FINAL RESULT'); + console.log('='.repeat(60)); + console.log(`Overall: ${allPassed ? 'ALL MODEL FAMILIES PASSED' : 'SOME MODEL FAMILIES FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-oauth-no-browser.cjs b/tests/test-oauth-no-browser.cjs new file mode 100755 index 0000000000000000000000000000000000000000..c67c6f54930daffebb61e0b576795deff530284a --- /dev/null +++ b/tests/test-oauth-no-browser.cjs @@ -0,0 +1,217 @@ +/** + * OAuth No-Browser Mode Unit Tests + * + * Tests the extractCodeFromInput() function which enables OAuth authentication + * on headless servers without a desktop environment. + * + * ============================================================================ + * FEATURE: --no-browser OAuth Mode + * ============================================================================ + * + * PURPOSE: + * Allow users to add Google accounts on remote servers (headless Linux, + * Docker containers, SSH sessions) where automatic browser opening is + * not possible. + * + * USAGE: + * npm run accounts:add -- --no-browser + * + * USER FLOW: + * 1. User runs command on headless server + * 2. System displays Google OAuth URL + * 3. User opens URL on another device (phone/laptop) with a browser + * 4. User signs in to Google and authorizes the app + * 5. Browser redirects to localhost (page won't load - this is expected) + * 6. User copies the redirect URL or authorization code from address bar + * 7. User pastes into server terminal + * 8. System extracts code using extractCodeFromInput() (tested here) + * 9. Account is added successfully + * + * FUNCTION UNDER TEST: + * extractCodeFromInput(input: string) => { code: string, state: string|null } + * + * Accepts either: + * - Full callback URL: http://localhost:51121/callback?code=xxx&state=yyy + * - Raw authorization code: 4/0AQSTgQG... + * + * Throws on: + * - Empty/null input + * - Too short input (< 10 chars) + * - URL with OAuth error parameter + * - URL without code parameter + * + * ============================================================================ + * + * Run: node tests/test-oauth-no-browser.cjs + */ + +// Note: Using dynamic import because oauth.js is ESM +async function runTests() { + console.log('='.repeat(60)); + console.log('OAUTH NO-BROWSER MODE UNIT TESTS'); + console.log('Testing: extractCodeFromInput()'); + console.log('='.repeat(60)); + console.log(''); + + // Import the ESM module + const { extractCodeFromInput } = await import('../src/auth/oauth.js'); + + let allPassed = true; + const results = []; + + /** + * Helper to run a single test case + * @param {string} name - Test name + * @param {Function} testFn - Test function that returns { passed, message } + */ + async function test(name, testFn) { + try { + const { passed, message } = await testFn(); + results.push({ name, passed, message }); + const status = passed ? 'PASS' : 'FAIL'; + console.log(` [${status}] ${name}`); + if (message) console.log(` ${message}`); + if (!passed) allPassed = false; + } catch (error) { + results.push({ name, passed: false, message: error.message }); + console.log(` [FAIL] ${name}`); + console.log(` Error: ${error.message}`); + allPassed = false; + } + } + + // ===== Test Group 1: Valid URL Inputs ===== + console.log('\n--- Valid URL Inputs ---'); + + await test('Parse full callback URL with code and state', () => { + const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg123&state=abc123'; + const result = extractCodeFromInput(input); + const passed = result.code === '4/0AQSTg123' && result.state === 'abc123'; + return { passed, message: `code=${result.code}, state=${result.state}` }; + }); + + await test('Parse URL with only code (no state)', () => { + const input = 'http://localhost:51121/oauth-callback?code=4/0AQSTg456'; + const result = extractCodeFromInput(input); + const passed = result.code === '4/0AQSTg456' && result.state === null; + return { passed, message: `code=${result.code}, state=${result.state}` }; + }); + + await test('Parse HTTPS URL', () => { + const input = 'https://localhost:51121/callback?code=secureCode123&state=xyz'; + const result = extractCodeFromInput(input); + const passed = result.code === 'secureCode123'; + return { passed, message: `code=${result.code}` }; + }); + + await test('Parse URL with additional query params', () => { + const input = 'http://localhost:51121/?code=myCode&state=myState&scope=email'; + const result = extractCodeFromInput(input); + const passed = result.code === 'myCode' && result.state === 'myState'; + return { passed, message: `code=${result.code}, state=${result.state}` }; + }); + + // ===== Test Group 2: Raw Code Inputs ===== + console.log('\n--- Raw Authorization Code Inputs ---'); + + await test('Parse raw authorization code (Google format)', () => { + const input = '4/0AQSTgQGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; + const result = extractCodeFromInput(input); + const passed = result.code === input && result.state === null; + return { passed, message: `code length=${result.code.length}` }; + }); + + await test('Parse raw code with whitespace (should trim)', () => { + const input = ' 4/0AQSTgQGcode123 \n'; + const result = extractCodeFromInput(input); + const passed = result.code === '4/0AQSTgQGcode123' && result.state === null; + return { passed, message: `trimmed code=${result.code}` }; + }); + + // ===== Test Group 3: Error Cases ===== + console.log('\n--- Error Handling ---'); + + await test('Throw on empty input', () => { + try { + extractCodeFromInput(''); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('No input'), message: e.message }; + } + }); + + await test('Throw on null input', () => { + try { + extractCodeFromInput(null); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('No input'), message: e.message }; + } + }); + + await test('Throw on too short code', () => { + try { + extractCodeFromInput('abc'); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('too short'), message: e.message }; + } + }); + + await test('Throw on OAuth error in URL', () => { + try { + const input = 'http://localhost:51121/?error=access_denied&error_description=User%20denied'; + extractCodeFromInput(input); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('OAuth error'), message: e.message }; + } + }); + + await test('Throw on URL without code param', () => { + try { + extractCodeFromInput('http://localhost:51121/callback?state=onlyState'); + return { passed: false, message: 'Should have thrown' }; + } catch (e) { + return { passed: e.message.includes('No authorization code'), message: e.message }; + } + }); + + // ===== Test Group 4: Edge Cases ===== + console.log('\n--- Edge Cases ---'); + + await test('Handle URL-encoded characters in code', () => { + const input = 'http://localhost:51121/?code=4%2F0AQSTg%2B%2B&state=test'; + const result = extractCodeFromInput(input); + // URL class automatically decodes + const passed = result.code === '4/0AQSTg++'; + return { passed, message: `decoded code=${result.code}` }; + }); + + await test('Accept minimum valid code length (10 chars)', () => { + const input = '1234567890'; + const result = extractCodeFromInput(input); + const passed = result.code === input; + return { passed, message: `code=${result.code}` }; + }); + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + const passed = results.filter(r => r.passed).length; + const failed = results.filter(r => !r.passed).length; + console.log(` Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`); + + console.log('\n' + '='.repeat(60)); + console.log(allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +}); diff --git a/tests/test-schema-sanitizer.cjs b/tests/test-schema-sanitizer.cjs new file mode 100755 index 0000000000000000000000000000000000000000..da36fe8c62de41ebd6baea2c1cfa833a10ff42bf --- /dev/null +++ b/tests/test-schema-sanitizer.cjs @@ -0,0 +1,269 @@ +/** + * Test Schema Sanitizer - Tests for schema transformation for Google API + * + * Verifies that complex nested array schemas are properly converted to + * Google's protobuf format (uppercase type names) to fix issue #82: + * "Proto field is not repeating, cannot start list" + */ + +// Import the schema sanitizer functions +const path = require('path'); + +// Since we're in CommonJS and the module is ESM, we need to use dynamic import +async function runTests() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ SCHEMA SANITIZER TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Dynamic import for ESM module + const { sanitizeSchema, cleanSchema } = await import('../src/format/schema-sanitizer.js'); + + let passed = 0; + let failed = 0; + + function test(name, fn) { + try { + fn(); + console.log(`✓ ${name}`); + passed++; + } catch (e) { + console.log(`✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } + } + + function assertEqual(actual, expected, message = '') { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}\nExpected: ${JSON.stringify(expected, null, 2)}\nActual: ${JSON.stringify(actual, null, 2)}`); + } + } + + function assertIncludes(actual, substring, message = '') { + if (!JSON.stringify(actual).includes(substring)) { + throw new Error(`${message}\nExpected to include: ${substring}\nActual: ${JSON.stringify(actual, null, 2)}`); + } + } + + // Test 1: Basic type conversion to uppercase + test('Basic type conversion to uppercase', () => { + const schema = { type: 'string', description: 'A test string' }; + const result = cleanSchema(sanitizeSchema(schema)); + assertEqual(result.type, 'STRING', 'Type should be uppercase STRING'); + }); + + // Test 2: Object type conversion + test('Object type conversion', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + }; + const result = cleanSchema(sanitizeSchema(schema)); + assertEqual(result.type, 'OBJECT', 'Object type should be uppercase'); + assertEqual(result.properties.name.type, 'STRING', 'Nested string type should be uppercase'); + assertEqual(result.properties.age.type, 'INTEGER', 'Nested integer type should be uppercase'); + }); + + // Test 3: Array type conversion (the main bug fix) + test('Array type conversion with items', () => { + const schema = { + type: 'array', + items: { + type: 'string' + } + }; + const result = cleanSchema(sanitizeSchema(schema)); + assertEqual(result.type, 'ARRAY', 'Array type should be uppercase ARRAY'); + assertEqual(result.items.type, 'STRING', 'Items type should be uppercase STRING'); + }); + + // Test 4: Nested array inside object (the actual bug scenario) + test('Nested array inside object (Claude Code TodoWrite-style schema)', () => { + const schema = { + type: 'object', + properties: { + todos: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + title: { type: 'string' }, + status: { type: 'string' } + } + } + } + } + }; + const result = cleanSchema(sanitizeSchema(schema)); + + assertEqual(result.type, 'OBJECT', 'Root type should be OBJECT'); + assertEqual(result.properties.todos.type, 'ARRAY', 'Todos type should be ARRAY'); + assertEqual(result.properties.todos.items.type, 'OBJECT', 'Items type should be OBJECT'); + assertEqual(result.properties.todos.items.properties.id.type, 'INTEGER', 'id type should be INTEGER'); + assertEqual(result.properties.todos.items.properties.title.type, 'STRING', 'title type should be STRING'); + }); + + // Test 5: Complex nested structure (simulating Claude Code tools) + test('Complex nested structure with multiple array levels', () => { + const schema = { + type: 'object', + properties: { + tasks: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + subtasks: { + type: 'array', + items: { + type: 'object', + properties: { + step: { type: 'string' }, + completed: { type: 'boolean' } + } + } + } + } + } + }, + count: { type: 'number' } + } + }; + const result = cleanSchema(sanitizeSchema(schema)); + + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.tasks.type, 'ARRAY'); + assertEqual(result.properties.tasks.items.type, 'OBJECT'); + assertEqual(result.properties.tasks.items.properties.subtasks.type, 'ARRAY'); + assertEqual(result.properties.tasks.items.properties.subtasks.items.type, 'OBJECT'); + assertEqual(result.properties.tasks.items.properties.subtasks.items.properties.completed.type, 'BOOLEAN'); + assertEqual(result.properties.count.type, 'NUMBER'); + }); + + // Test 6: cleanSchema handles anyOf (when not stripped by sanitizeSchema) + test('cleanSchema handles anyOf and converts types', () => { + // Test cleanSchema directly with anyOf (bypassing sanitizeSchema) + const schema = { + type: 'object', + properties: { + value: { + anyOf: [ + { type: 'string' }, + { type: 'object', properties: { name: { type: 'string' } } } + ] + } + } + }; + const result = cleanSchema(schema); + + assertEqual(result.type, 'OBJECT'); + // anyOf gets flattened to best option (object type scores highest) + assertEqual(result.properties.value.type, 'OBJECT'); + }); + + // Test 7: Schema with type array (nullable) + test('Schema with type array (nullable) gets flattened and converted', () => { + const schema = { + type: 'object', + properties: { + optional: { + type: ['string', 'null'] + } + } + }; + const result = cleanSchema(sanitizeSchema(schema)); + + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.optional.type, 'STRING'); + }); + + // Test 8: All primitive types + test('All primitive types converted correctly', () => { + const schema = { + type: 'object', + properties: { + str: { type: 'string' }, + num: { type: 'number' }, + int: { type: 'integer' }, + bool: { type: 'boolean' }, + arr: { type: 'array', items: { type: 'string' } }, + obj: { type: 'object', properties: { x: { type: 'string' } } } + } + }; + const result = cleanSchema(sanitizeSchema(schema)); + + assertEqual(result.properties.str.type, 'STRING'); + assertEqual(result.properties.num.type, 'NUMBER'); + assertEqual(result.properties.int.type, 'INTEGER'); + assertEqual(result.properties.bool.type, 'BOOLEAN'); + assertEqual(result.properties.arr.type, 'ARRAY'); + assertEqual(result.properties.obj.type, 'OBJECT'); + }); + + // Test 9: Empty schema gets placeholder with correct types + test('Empty schema gets placeholder with uppercase types', () => { + const result = cleanSchema(sanitizeSchema(null)); + + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.reason.type, 'STRING'); + }); + + // Test 10: Real-world Claude Code tool schema simulation + test('Real-world Claude Code ManageTodoList-style schema', () => { + // Simulates the type of schema that caused issue #82 + const schema = { + type: 'object', + properties: { + operation: { + type: 'string', + enum: ['write', 'read'] + }, + todoList: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + title: { type: 'string' }, + status: { + type: 'string', + enum: ['not-started', 'in-progress', 'completed'] + } + }, + required: ['id', 'title', 'status'] + } + } + }, + required: ['operation'] + }; + + const result = cleanSchema(sanitizeSchema(schema)); + + // Verify all types are uppercase + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.operation.type, 'STRING'); + assertEqual(result.properties.todoList.type, 'ARRAY'); + assertEqual(result.properties.todoList.items.type, 'OBJECT'); + assertEqual(result.properties.todoList.items.properties.id.type, 'NUMBER'); + assertEqual(result.properties.todoList.items.properties.title.type, 'STRING'); + assertEqual(result.properties.todoList.items.properties.status.type, 'STRING'); + }); + + // Summary + console.log('\n' + '═'.repeat(60)); + console.log(`Tests completed: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +}); diff --git a/tests/test-strategies.cjs b/tests/test-strategies.cjs new file mode 100755 index 0000000000000000000000000000000000000000..643403b07de163f14fa427d94597c8fb7be3a28c --- /dev/null +++ b/tests/test-strategies.cjs @@ -0,0 +1,1055 @@ +/** + * Test Account Selection Strategies - Unit Tests + * + * Tests the strategy pattern implementation for account selection: + * - HealthTracker: health score tracking with passive recovery + * - TokenBucketTracker: token bucket rate limiting + * - StickyStrategy: cache-optimized sticky selection + * - RoundRobinStrategy: load-balanced rotation + * - HybridStrategy: smart multi-signal distribution + * - Strategy Factory: createStrategy, isValidStrategy, getStrategyLabel + */ + +// Since we're in CommonJS and the module is ESM, we need to use dynamic import +async function runTests() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ ACCOUNT SELECTION STRATEGY TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Dynamic imports for ESM modules + const { HealthTracker } = await import('../src/account-manager/strategies/trackers/health-tracker.js'); + const { TokenBucketTracker } = await import('../src/account-manager/strategies/trackers/token-bucket-tracker.js'); + const { QuotaTracker } = await import('../src/account-manager/strategies/trackers/quota-tracker.js'); + const { StickyStrategy } = await import('../src/account-manager/strategies/sticky-strategy.js'); + const { RoundRobinStrategy } = await import('../src/account-manager/strategies/round-robin-strategy.js'); + const { HybridStrategy } = await import('../src/account-manager/strategies/hybrid-strategy.js'); + const { BaseStrategy } = await import('../src/account-manager/strategies/base-strategy.js'); + const { + createStrategy, + isValidStrategy, + getStrategyLabel, + STRATEGY_NAMES, + DEFAULT_STRATEGY + } = await import('../src/account-manager/strategies/index.js'); + + let passed = 0; + let failed = 0; + + function test(name, fn) { + try { + fn(); + console.log(`✓ ${name}`); + passed++; + } catch (e) { + console.log(`✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } + } + + function assertEqual(actual, expected, message = '') { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`); + } + } + + function assertDeepEqual(actual, expected, message = '') { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}\nExpected: ${JSON.stringify(expected, null, 2)}\nActual: ${JSON.stringify(actual, null, 2)}`); + } + } + + function assertTrue(value, message = '') { + if (!value) { + throw new Error(message || 'Expected true but got false'); + } + } + + function assertFalse(value, message = '') { + if (value) { + throw new Error(message || 'Expected false but got true'); + } + } + + function assertNull(value, message = '') { + if (value !== null) { + throw new Error(`${message}\nExpected null but got: ${value}`); + } + } + + function assertNotNull(value, message = '') { + if (value === null || value === undefined) { + throw new Error(`${message}\nExpected non-null value but got: ${value}`); + } + } + + function assertWithin(actual, min, max, message = '') { + if (actual < min || actual > max) { + throw new Error(`${message}\nExpected value between ${min} and ${max}, got: ${actual}`); + } + } + + // Helper to create mock accounts + function createMockAccounts(count = 3, options = {}) { + return Array.from({ length: count }, (_, i) => ({ + email: `account${i + 1}@example.com`, + enabled: true, + isInvalid: false, + lastUsed: Date.now() - (i * 60000), // Stagger by 1 minute + modelRateLimits: {}, + ...options + })); + } + + // ========================================================================== + // HEALTH TRACKER TESTS + // ========================================================================== + console.log('\n─── HealthTracker Tests ───'); + + test('HealthTracker: initial score is 70 by default', () => { + const tracker = new HealthTracker(); + const score = tracker.getScore('new@example.com'); + assertEqual(score, 70, 'Default initial score should be 70'); + }); + + test('HealthTracker: custom initial score', () => { + const tracker = new HealthTracker({ initial: 80 }); + const score = tracker.getScore('new@example.com'); + assertEqual(score, 80, 'Custom initial score should be 80'); + }); + + test('HealthTracker: recordSuccess increases score', () => { + const tracker = new HealthTracker({ initial: 70, successReward: 1 }); + tracker.recordSuccess('test@example.com'); + const score = tracker.getScore('test@example.com'); + assertEqual(score, 71, 'Score should increase by 1 on success'); + }); + + test('HealthTracker: recordRateLimit decreases score', () => { + const tracker = new HealthTracker({ initial: 70, rateLimitPenalty: -10 }); + tracker.recordRateLimit('test@example.com'); + const score = tracker.getScore('test@example.com'); + assertEqual(score, 60, 'Score should decrease by 10 on rate limit'); + }); + + test('HealthTracker: recordFailure decreases score', () => { + const tracker = new HealthTracker({ initial: 70, failurePenalty: -20 }); + tracker.recordFailure('test@example.com'); + const score = tracker.getScore('test@example.com'); + assertEqual(score, 50, 'Score should decrease by 20 on failure'); + }); + + test('HealthTracker: score cannot exceed maxScore', () => { + const tracker = new HealthTracker({ initial: 99, maxScore: 100, successReward: 5 }); + tracker.recordSuccess('test@example.com'); + const score = tracker.getScore('test@example.com'); + assertEqual(score, 100, 'Score should be capped at maxScore'); + }); + + test('HealthTracker: score cannot go below 0', () => { + const tracker = new HealthTracker({ initial: 10, failurePenalty: -50 }); + tracker.recordFailure('test@example.com'); + const score = tracker.getScore('test@example.com'); + assertEqual(score, 0, 'Score should not go below 0'); + }); + + test('HealthTracker: isUsable returns true when score >= minUsable', () => { + const tracker = new HealthTracker({ initial: 50, minUsable: 50 }); + assertTrue(tracker.isUsable('test@example.com'), 'Should be usable at minUsable'); + }); + + test('HealthTracker: isUsable returns false when score < minUsable', () => { + const tracker = new HealthTracker({ initial: 49, minUsable: 50 }); + assertFalse(tracker.isUsable('test@example.com'), 'Should not be usable below minUsable'); + }); + + test('HealthTracker: reset restores initial score', () => { + const tracker = new HealthTracker({ initial: 70 }); + tracker.recordFailure('test@example.com'); // Score drops + tracker.reset('test@example.com'); + const score = tracker.getScore('test@example.com'); + assertEqual(score, 70, 'Reset should restore initial score'); + }); + + test('HealthTracker: clear removes all scores', () => { + const tracker = new HealthTracker({ initial: 70 }); + tracker.recordSuccess('a@example.com'); + tracker.recordSuccess('b@example.com'); + tracker.clear(); + // After clear, new accounts should get initial score + assertEqual(tracker.getScore('a@example.com'), 70); + assertEqual(tracker.getScore('b@example.com'), 70); + }); + + test('HealthTracker: getConsecutiveFailures returns 0 for new account', () => { + const tracker = new HealthTracker(); + assertEqual(tracker.getConsecutiveFailures('new@example.com'), 0); + }); + + test('HealthTracker: recordRateLimit increments consecutiveFailures', () => { + const tracker = new HealthTracker(); + tracker.recordRateLimit('test@example.com'); + assertEqual(tracker.getConsecutiveFailures('test@example.com'), 1); + tracker.recordRateLimit('test@example.com'); + assertEqual(tracker.getConsecutiveFailures('test@example.com'), 2); + }); + + test('HealthTracker: recordFailure increments consecutiveFailures', () => { + const tracker = new HealthTracker(); + tracker.recordFailure('test@example.com'); + assertEqual(tracker.getConsecutiveFailures('test@example.com'), 1); + }); + + test('HealthTracker: recordSuccess resets consecutiveFailures', () => { + const tracker = new HealthTracker(); + tracker.recordRateLimit('test@example.com'); + tracker.recordRateLimit('test@example.com'); + assertEqual(tracker.getConsecutiveFailures('test@example.com'), 2); + tracker.recordSuccess('test@example.com'); + assertEqual(tracker.getConsecutiveFailures('test@example.com'), 0); + }); + + test('HealthTracker: reset clears consecutiveFailures', () => { + const tracker = new HealthTracker(); + tracker.recordFailure('test@example.com'); + tracker.recordFailure('test@example.com'); + assertEqual(tracker.getConsecutiveFailures('test@example.com'), 2); + tracker.reset('test@example.com'); + assertEqual(tracker.getConsecutiveFailures('test@example.com'), 0); + }); + + // ========================================================================== + // TOKEN BUCKET TRACKER TESTS + // ========================================================================== + console.log('\n─── TokenBucketTracker Tests ───'); + + test('TokenBucketTracker: initial tokens is 50 by default', () => { + const tracker = new TokenBucketTracker(); + const tokens = tracker.getTokens('new@example.com'); + assertEqual(tokens, 50, 'Default initial tokens should be 50'); + }); + + test('TokenBucketTracker: custom initial tokens', () => { + const tracker = new TokenBucketTracker({ initialTokens: 30 }); + const tokens = tracker.getTokens('new@example.com'); + assertEqual(tokens, 30, 'Custom initial tokens should be 30'); + }); + + test('TokenBucketTracker: consume decreases tokens', () => { + const tracker = new TokenBucketTracker({ initialTokens: 10, maxTokens: 10 }); + const consumed = tracker.consume('test@example.com'); + assertTrue(consumed, 'Consume should return true'); + assertEqual(tracker.getTokens('test@example.com'), 9, 'Tokens should decrease by 1'); + }); + + test('TokenBucketTracker: consume fails when no tokens', () => { + const tracker = new TokenBucketTracker({ initialTokens: 0, maxTokens: 10 }); + const consumed = tracker.consume('test@example.com'); + assertFalse(consumed, 'Consume should return false when no tokens'); + }); + + test('TokenBucketTracker: hasTokens returns true when tokens > 0', () => { + const tracker = new TokenBucketTracker({ initialTokens: 1 }); + assertTrue(tracker.hasTokens('test@example.com'), 'Should have tokens'); + }); + + test('TokenBucketTracker: hasTokens returns false when tokens < 1', () => { + const tracker = new TokenBucketTracker({ initialTokens: 0 }); + assertFalse(tracker.hasTokens('test@example.com'), 'Should not have tokens'); + }); + + test('TokenBucketTracker: refund increases tokens', () => { + const tracker = new TokenBucketTracker({ initialTokens: 5, maxTokens: 10 }); + tracker.consume('test@example.com'); // 5 -> 4 + tracker.refund('test@example.com'); // 4 -> 5 + assertEqual(tracker.getTokens('test@example.com'), 5, 'Refund should restore token'); + }); + + test('TokenBucketTracker: refund cannot exceed maxTokens', () => { + const tracker = new TokenBucketTracker({ initialTokens: 10, maxTokens: 10 }); + tracker.refund('test@example.com'); + assertEqual(tracker.getTokens('test@example.com'), 10, 'Refund should not exceed max'); + }); + + test('TokenBucketTracker: getMaxTokens returns configured max', () => { + const tracker = new TokenBucketTracker({ maxTokens: 100 }); + assertEqual(tracker.getMaxTokens(), 100, 'getMaxTokens should return 100'); + }); + + test('TokenBucketTracker: reset restores initial tokens', () => { + const tracker = new TokenBucketTracker({ initialTokens: 50, maxTokens: 50 }); + tracker.consume('test@example.com'); + tracker.consume('test@example.com'); + tracker.reset('test@example.com'); + assertEqual(tracker.getTokens('test@example.com'), 50, 'Reset should restore initial'); + }); + + // ========================================================================== + // QUOTA TRACKER TESTS + // ========================================================================== + console.log('\n─── QuotaTracker Tests ───'); + + test('QuotaTracker: getQuotaFraction returns null for missing data', () => { + const tracker = new QuotaTracker(); + const account = { email: 'test@example.com' }; + assertNull(tracker.getQuotaFraction(account, 'model'), 'Missing quota should return null'); + }); + + test('QuotaTracker: getQuotaFraction returns correct value', () => { + const tracker = new QuotaTracker(); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 0.75 } }, + lastChecked: Date.now() + } + }; + assertEqual(tracker.getQuotaFraction(account, 'model'), 0.75); + }); + + test('QuotaTracker: isQuotaFresh returns false when no lastChecked', () => { + const tracker = new QuotaTracker(); + const account = { email: 'test@example.com' }; + assertFalse(tracker.isQuotaFresh(account), 'Missing lastChecked should not be fresh'); + }); + + test('QuotaTracker: isQuotaFresh returns true for recent data', () => { + const tracker = new QuotaTracker({ staleMs: 300000 }); // 5 min + const account = { + email: 'test@example.com', + quota: { lastChecked: Date.now() - 60000 } // 1 min ago + }; + assertTrue(tracker.isQuotaFresh(account), 'Recent data should be fresh'); + }); + + test('QuotaTracker: isQuotaFresh returns false for stale data', () => { + const tracker = new QuotaTracker({ staleMs: 300000 }); // 5 min + const account = { + email: 'test@example.com', + quota: { lastChecked: Date.now() - 600000 } // 10 min ago + }; + assertFalse(tracker.isQuotaFresh(account), 'Old data should be stale'); + }); + + test('QuotaTracker: isQuotaCritical returns false for unknown quota', () => { + const tracker = new QuotaTracker({ criticalThreshold: 0.05 }); + const account = { email: 'test@example.com' }; + assertFalse(tracker.isQuotaCritical(account, 'model'), 'Unknown quota should not be critical'); + }); + + test('QuotaTracker: isQuotaCritical returns true when quota <= threshold', () => { + const tracker = new QuotaTracker({ criticalThreshold: 0.05 }); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 0.04 } }, + lastChecked: Date.now() + } + }; + assertTrue(tracker.isQuotaCritical(account, 'model'), 'Low quota should be critical'); + }); + + test('QuotaTracker: isQuotaCritical returns false when quota > threshold', () => { + const tracker = new QuotaTracker({ criticalThreshold: 0.05 }); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 0.10 } }, + lastChecked: Date.now() + } + }; + assertFalse(tracker.isQuotaCritical(account, 'model'), 'Higher quota should not be critical'); + }); + + test('QuotaTracker: isQuotaCritical returns false for stale data', () => { + const tracker = new QuotaTracker({ criticalThreshold: 0.05, staleMs: 300000 }); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 0.01 } }, + lastChecked: Date.now() - 600000 // 10 min ago (stale) + } + }; + assertFalse(tracker.isQuotaCritical(account, 'model'), 'Stale critical data should be ignored'); + }); + + test('QuotaTracker: isQuotaLow returns true for low but not critical quota', () => { + const tracker = new QuotaTracker({ lowThreshold: 0.10, criticalThreshold: 0.05 }); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 0.08 } }, + lastChecked: Date.now() + } + }; + assertTrue(tracker.isQuotaLow(account, 'model'), 'Quota at 8% should be low'); + }); + + test('QuotaTracker: isQuotaLow returns false for critical quota', () => { + const tracker = new QuotaTracker({ lowThreshold: 0.10, criticalThreshold: 0.05 }); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 0.03 } }, + lastChecked: Date.now() + } + }; + assertFalse(tracker.isQuotaLow(account, 'model'), 'Critical quota should not be just low'); + }); + + test('QuotaTracker: getScore returns unknownScore for missing quota', () => { + const tracker = new QuotaTracker({ unknownScore: 50 }); + const account = { email: 'test@example.com' }; + assertEqual(tracker.getScore(account, 'model'), 50, 'Unknown quota should return default score'); + }); + + test('QuotaTracker: getScore returns 0-100 based on fraction', () => { + const tracker = new QuotaTracker(); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 0.75 } }, + lastChecked: Date.now() + } + }; + assertEqual(tracker.getScore(account, 'model'), 75, 'Score should be fraction * 100'); + }); + + test('QuotaTracker: getScore applies penalty for stale data', () => { + const tracker = new QuotaTracker({ staleMs: 300000 }); + const account = { + email: 'test@example.com', + quota: { + models: { 'model': { remainingFraction: 1.0 } }, + lastChecked: Date.now() - 600000 // 10 min ago + } + }; + assertEqual(tracker.getScore(account, 'model'), 90, 'Stale data should have 10% penalty'); + }); + + // ========================================================================== + // BASE STRATEGY TESTS + // ========================================================================== + console.log('\n─── BaseStrategy Tests ───'); + + test('BaseStrategy: cannot be instantiated directly', () => { + try { + new BaseStrategy(); + throw new Error('Should have thrown'); + } catch (e) { + assertTrue(e.message.includes('abstract'), 'Should throw abstract error'); + } + }); + + test('BaseStrategy: isAccountUsable returns false for null account', () => { + // Create a minimal subclass to test + class TestStrategy extends BaseStrategy { + selectAccount() { return { account: null, index: 0 }; } + } + const strategy = new TestStrategy(); + assertFalse(strategy.isAccountUsable(null, 'model'), 'Null account should not be usable'); + }); + + test('BaseStrategy: isAccountUsable returns false for invalid account', () => { + class TestStrategy extends BaseStrategy { + selectAccount() { return { account: null, index: 0 }; } + } + const strategy = new TestStrategy(); + const account = { email: 'test@example.com', isInvalid: true }; + assertFalse(strategy.isAccountUsable(account, 'model'), 'Invalid account should not be usable'); + }); + + test('BaseStrategy: isAccountUsable returns false for disabled account', () => { + class TestStrategy extends BaseStrategy { + selectAccount() { return { account: null, index: 0 }; } + } + const strategy = new TestStrategy(); + const account = { email: 'test@example.com', enabled: false }; + assertFalse(strategy.isAccountUsable(account, 'model'), 'Disabled account should not be usable'); + }); + + test('BaseStrategy: isAccountUsable returns false for rate-limited model', () => { + class TestStrategy extends BaseStrategy { + selectAccount() { return { account: null, index: 0 }; } + } + const strategy = new TestStrategy(); + const account = { + email: 'test@example.com', + modelRateLimits: { + 'claude-sonnet': { + isRateLimited: true, + resetTime: Date.now() + 60000 // 1 minute in future + } + } + }; + assertFalse(strategy.isAccountUsable(account, 'claude-sonnet'), 'Rate-limited model should not be usable'); + }); + + test('BaseStrategy: isAccountUsable returns true for expired rate limit', () => { + class TestStrategy extends BaseStrategy { + selectAccount() { return { account: null, index: 0 }; } + } + const strategy = new TestStrategy(); + const account = { + email: 'test@example.com', + modelRateLimits: { + 'claude-sonnet': { + isRateLimited: true, + resetTime: Date.now() - 1000 // 1 second in past + } + } + }; + assertTrue(strategy.isAccountUsable(account, 'claude-sonnet'), 'Expired rate limit should be usable'); + }); + + test('BaseStrategy: getUsableAccounts filters correctly', () => { + class TestStrategy extends BaseStrategy { + selectAccount() { return { account: null, index: 0 }; } + } + const strategy = new TestStrategy(); + const accounts = [ + { email: 'a@example.com', enabled: true }, + { email: 'b@example.com', enabled: false }, + { email: 'c@example.com', enabled: true, isInvalid: true }, + { email: 'd@example.com', enabled: true } + ]; + const usable = strategy.getUsableAccounts(accounts, 'model'); + assertEqual(usable.length, 2, 'Should have 2 usable accounts'); + assertEqual(usable[0].account.email, 'a@example.com'); + assertEqual(usable[1].account.email, 'd@example.com'); + }); + + // ========================================================================== + // STICKY STRATEGY TESTS + // ========================================================================== + console.log('\n─── StickyStrategy Tests ───'); + + test('StickyStrategy: returns null for empty accounts', () => { + const strategy = new StickyStrategy(); + const result = strategy.selectAccount([], 'model', { currentIndex: 0 }); + assertNull(result.account, 'Should return null for empty accounts'); + }); + + test('StickyStrategy: keeps using current account when available', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(3); + + const result1 = strategy.selectAccount(accounts, 'model', { currentIndex: 0 }); + assertEqual(result1.account.email, 'account1@example.com'); + assertEqual(result1.index, 0); + + const result2 = strategy.selectAccount(accounts, 'model', { currentIndex: 0 }); + assertEqual(result2.account.email, 'account1@example.com', 'Should stick to same account'); + assertEqual(result2.index, 0); + }); + + test('StickyStrategy: switches when current account is rate-limited', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(3); + // Rate-limit account1 for 5 minutes (longer than MAX_WAIT) + accounts[0].modelRateLimits = { + 'model': { isRateLimited: true, resetTime: Date.now() + 300000 } + }; + + const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 }); + assertEqual(result.account.email, 'account2@example.com', 'Should switch to next available'); + assertEqual(result.index, 1); + }); + + test('StickyStrategy: returns waitMs when current account has short rate limit', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(1); // Only one account + // Rate-limit for 30 seconds (less than MAX_WAIT of 2 minutes) + accounts[0].modelRateLimits = { + 'model': { isRateLimited: true, resetTime: Date.now() + 30000 } + }; + + const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 }); + assertNull(result.account, 'Should return null when waiting'); + assertWithin(result.waitMs, 29000, 31000, 'Should return ~30s wait time'); + }); + + test('StickyStrategy: switches when current account is disabled', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(3); + accounts[0].enabled = false; + + const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 }); + assertEqual(result.account.email, 'account2@example.com', 'Should switch to next'); + }); + + test('StickyStrategy: switches when current account is invalid', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(3); + accounts[0].isInvalid = true; + + const result = strategy.selectAccount(accounts, 'model', { currentIndex: 0 }); + assertEqual(result.account.email, 'account2@example.com', 'Should switch to next'); + }); + + test('StickyStrategy: wraps around when at end of list', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(3); + accounts[2].isInvalid = true; // Last account invalid + + const result = strategy.selectAccount(accounts, 'model', { currentIndex: 2 }); + assertEqual(result.account.email, 'account1@example.com', 'Should wrap to first'); + assertEqual(result.index, 0); + }); + + test('StickyStrategy: clamps invalid currentIndex', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(3); + + const result = strategy.selectAccount(accounts, 'model', { currentIndex: 10 }); + assertEqual(result.account.email, 'account1@example.com', 'Should clamp to valid index'); + assertEqual(result.index, 0); + }); + + // ========================================================================== + // ROUND-ROBIN STRATEGY TESTS + // ========================================================================== + console.log('\n─── RoundRobinStrategy Tests ───'); + + test('RoundRobinStrategy: returns null for empty accounts', () => { + const strategy = new RoundRobinStrategy(); + const result = strategy.selectAccount([], 'model'); + assertNull(result.account, 'Should return null for empty accounts'); + }); + + test('RoundRobinStrategy: rotates through accounts', () => { + const strategy = new RoundRobinStrategy(); + const accounts = createMockAccounts(3); + + const r1 = strategy.selectAccount(accounts, 'model'); + const r2 = strategy.selectAccount(accounts, 'model'); + const r3 = strategy.selectAccount(accounts, 'model'); + const r4 = strategy.selectAccount(accounts, 'model'); + + // First call starts at cursor 0, looks at (0+1)%3 = 1 + // Then cursor becomes 1, next looks at (1+1)%3 = 2 + // Then cursor becomes 2, next looks at (2+1)%3 = 0 + // Then cursor becomes 0, next looks at (0+1)%3 = 1 + assertEqual(r1.account.email, 'account2@example.com', 'First should be account2'); + assertEqual(r2.account.email, 'account3@example.com', 'Second should be account3'); + assertEqual(r3.account.email, 'account1@example.com', 'Third should wrap to account1'); + assertEqual(r4.account.email, 'account2@example.com', 'Fourth should continue rotation'); + }); + + test('RoundRobinStrategy: skips unavailable accounts', () => { + const strategy = new RoundRobinStrategy(); + const accounts = createMockAccounts(3); + accounts[1].enabled = false; // Disable account2 + + const r1 = strategy.selectAccount(accounts, 'model'); + const r2 = strategy.selectAccount(accounts, 'model'); + const r3 = strategy.selectAccount(accounts, 'model'); + + // account2 is skipped + assertEqual(r1.account.email, 'account3@example.com'); + assertEqual(r2.account.email, 'account1@example.com'); + assertEqual(r3.account.email, 'account3@example.com'); + }); + + test('RoundRobinStrategy: returns null when all accounts unavailable', () => { + const strategy = new RoundRobinStrategy(); + const accounts = createMockAccounts(3); + accounts.forEach(a => a.enabled = false); + + const result = strategy.selectAccount(accounts, 'model'); + assertNull(result.account, 'Should return null when all unavailable'); + }); + + test('RoundRobinStrategy: resetCursor resets position', () => { + const strategy = new RoundRobinStrategy(); + const accounts = createMockAccounts(3); + + strategy.selectAccount(accounts, 'model'); // Moves cursor + strategy.selectAccount(accounts, 'model'); // Moves cursor + strategy.resetCursor(); + + const result = strategy.selectAccount(accounts, 'model'); + assertEqual(result.account.email, 'account2@example.com', 'Should start from beginning after reset'); + }); + + // ========================================================================== + // HYBRID STRATEGY TESTS + // ========================================================================== + console.log('\n─── HybridStrategy Tests ───'); + + test('HybridStrategy: returns null for empty accounts', () => { + const strategy = new HybridStrategy(); + const result = strategy.selectAccount([], 'model'); + assertNull(result.account, 'Should return null for empty accounts'); + }); + + test('HybridStrategy: selects best scored account', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70 }, + tokenBucket: { initialTokens: 50, maxTokens: 50 } + }); + const accounts = createMockAccounts(3); + // Make account3 older (higher LRU score) + accounts[2].lastUsed = Date.now() - 3600000; // 1 hour ago + + const result = strategy.selectAccount(accounts, 'model'); + // account3 should win due to higher LRU score + assertEqual(result.account.email, 'account3@example.com', 'Oldest account should be selected'); + }); + + test('HybridStrategy: uses emergency fallback for unhealthy accounts', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 40, minUsable: 50 }, + tokenBucket: { initialTokens: 50, maxTokens: 50 } + }); + const accounts = createMockAccounts(3); + + // All accounts start with health 40, which is below minUsable 50 + // But emergency fallback should still return an account + const result = strategy.selectAccount(accounts, 'model'); + assertNotNull(result.account, 'Emergency fallback should return an account'); + // waitMs indicates fallback was used (250ms for emergency) + assertTrue(result.waitMs >= 250, 'Emergency fallback should add throttle delay'); + }); + + test('HybridStrategy: uses last resort fallback for accounts without tokens', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70 }, + tokenBucket: { initialTokens: 0, maxTokens: 50 } + }); + const accounts = createMockAccounts(3); + + // No tokens, but last resort fallback should still return an account + const result = strategy.selectAccount(accounts, 'model'); + assertNotNull(result.account, 'Last resort fallback should return an account'); + // waitMs indicates fallback was used (500ms for lastResort) + assertTrue(result.waitMs >= 500, 'Last resort fallback should add throttle delay'); + }); + + test('HybridStrategy: consumes token on selection', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70 }, + tokenBucket: { initialTokens: 10, maxTokens: 50 } + }); + const accounts = createMockAccounts(1); + + strategy.selectAccount(accounts, 'model'); + const tracker = strategy.getTokenBucketTracker(); + assertEqual(tracker.getTokens(accounts[0].email), 9, 'Token should be consumed'); + }); + + test('HybridStrategy: onSuccess increases health', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70, successReward: 5 } + }); + const account = { email: 'test@example.com' }; + + strategy.onSuccess(account, 'model'); + const tracker = strategy.getHealthTracker(); + assertEqual(tracker.getScore('test@example.com'), 75, 'Health should increase'); + }); + + test('HybridStrategy: onRateLimit decreases health', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70, rateLimitPenalty: -10 } + }); + const account = { email: 'test@example.com' }; + + strategy.onRateLimit(account, 'model'); + const tracker = strategy.getHealthTracker(); + assertEqual(tracker.getScore('test@example.com'), 60, 'Health should decrease'); + }); + + test('HybridStrategy: onFailure decreases health and refunds token', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70, failurePenalty: -20 }, + tokenBucket: { initialTokens: 10, maxTokens: 50 } + }); + const accounts = createMockAccounts(1); + + // First consume a token + strategy.selectAccount(accounts, 'model'); + const tokensBefore = strategy.getTokenBucketTracker().getTokens(accounts[0].email); + + // Then fail + strategy.onFailure(accounts[0], 'model'); + + const healthTracker = strategy.getHealthTracker(); + const tokenTracker = strategy.getTokenBucketTracker(); + + assertEqual(healthTracker.getScore(accounts[0].email), 50, 'Health should decrease by 20'); + assertEqual(tokenTracker.getTokens(accounts[0].email), tokensBefore + 1, 'Token should be refunded'); + }); + + test('HybridStrategy: scoring formula weights work correctly', () => { + // Test that health, tokens, and LRU all contribute to score + const strategy = new HybridStrategy({ + healthScore: { initial: 100 }, + tokenBucket: { initialTokens: 50, maxTokens: 50 }, + weights: { health: 2, tokens: 5, lru: 0.1 } + }); + + const accounts = [ + { email: 'high-health@example.com', enabled: true, lastUsed: Date.now() }, + { email: 'old-account@example.com', enabled: true, lastUsed: Date.now() - 3600000 } + ]; + + // Both have same health and tokens, but old-account has higher LRU + const result = strategy.selectAccount(accounts, 'model'); + assertEqual(result.account.email, 'old-account@example.com', 'Older account should win with LRU weight'); + }); + + test('HybridStrategy: filters out accounts with critical quota', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70 }, + tokenBucket: { initialTokens: 50, maxTokens: 50 }, + quota: { criticalThreshold: 0.05, staleMs: 300000 } + }); + + const accounts = [ + { + email: 'critical@example.com', + enabled: true, + lastUsed: Date.now() - 3600000, // Older (would normally win LRU) + quota: { + models: { 'model': { remainingFraction: 0.02 } }, + lastChecked: Date.now() + } + }, + { + email: 'healthy@example.com', + enabled: true, + lastUsed: Date.now() + } + ]; + + const result = strategy.selectAccount(accounts, 'model'); + assertEqual(result.account.email, 'healthy@example.com', 'Critical quota account should be excluded'); + }); + + test('HybridStrategy: prefers higher quota accounts', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70 }, + tokenBucket: { initialTokens: 50, maxTokens: 50 }, + quota: { weight: 3 }, + weights: { health: 2, tokens: 5, quota: 3, lru: 0.1 } + }); + + // Create accounts with same lastUsed (equal LRU) + const now = Date.now(); + const accounts = [ + { + email: 'low-quota@example.com', + enabled: true, + lastUsed: now, + quota: { + models: { 'model': { remainingFraction: 0.20 } }, + lastChecked: now + } + }, + { + email: 'high-quota@example.com', + enabled: true, + lastUsed: now, + quota: { + models: { 'model': { remainingFraction: 0.80 } }, + lastChecked: now + } + } + ]; + + const result = strategy.selectAccount(accounts, 'model'); + assertEqual(result.account.email, 'high-quota@example.com', 'Higher quota account should be preferred'); + }); + + test('HybridStrategy: falls back when all accounts have critical quota', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70 }, + tokenBucket: { initialTokens: 50, maxTokens: 50 }, + quota: { criticalThreshold: 0.05, staleMs: 300000 } + }); + + const accounts = [ + { + email: 'critical1@example.com', + enabled: true, + lastUsed: Date.now() - 60000, + quota: { + models: { 'model': { remainingFraction: 0.02 } }, + lastChecked: Date.now() + } + }, + { + email: 'critical2@example.com', + enabled: true, + lastUsed: Date.now(), + quota: { + models: { 'model': { remainingFraction: 0.01 } }, + lastChecked: Date.now() + } + } + ]; + + // Should fall back and select an account even though all are critical + const result = strategy.selectAccount(accounts, 'model'); + assertTrue(result.account !== null, 'Should fall back to critical quota accounts when no alternatives'); + }); + + test('HybridStrategy: getQuotaTracker returns tracker', () => { + const strategy = new HybridStrategy(); + const tracker = strategy.getQuotaTracker(); + assertTrue(tracker instanceof QuotaTracker, 'Should return QuotaTracker instance'); + }); + + // ========================================================================== + // STRATEGY FACTORY TESTS + // ========================================================================== + console.log('\n─── Strategy Factory Tests ───'); + + test('createStrategy: creates StickyStrategy for "sticky"', () => { + const strategy = createStrategy('sticky'); + assertTrue(strategy instanceof StickyStrategy, 'Should create StickyStrategy'); + }); + + test('createStrategy: creates RoundRobinStrategy for "round-robin"', () => { + const strategy = createStrategy('round-robin'); + assertTrue(strategy instanceof RoundRobinStrategy, 'Should create RoundRobinStrategy'); + }); + + test('createStrategy: creates RoundRobinStrategy for "roundrobin"', () => { + const strategy = createStrategy('roundrobin'); + assertTrue(strategy instanceof RoundRobinStrategy, 'Should accept roundrobin alias'); + }); + + test('createStrategy: creates HybridStrategy for "hybrid"', () => { + const strategy = createStrategy('hybrid'); + assertTrue(strategy instanceof HybridStrategy, 'Should create HybridStrategy'); + }); + + test('createStrategy: falls back to HybridStrategy for unknown strategy', () => { + const strategy = createStrategy('unknown'); + assertTrue(strategy instanceof HybridStrategy, 'Should fall back to HybridStrategy'); + }); + + test('createStrategy: uses default when null', () => { + const strategy = createStrategy(null); + assertTrue(strategy instanceof HybridStrategy, 'Null should use default HybridStrategy'); + }); + + test('createStrategy: is case-insensitive', () => { + const s1 = createStrategy('STICKY'); + const s2 = createStrategy('Hybrid'); + const s3 = createStrategy('ROUND-ROBIN'); + assertTrue(s1 instanceof StickyStrategy); + assertTrue(s2 instanceof HybridStrategy); + assertTrue(s3 instanceof RoundRobinStrategy); + }); + + test('isValidStrategy: returns true for valid strategies', () => { + assertTrue(isValidStrategy('sticky')); + assertTrue(isValidStrategy('round-robin')); + assertTrue(isValidStrategy('hybrid')); + assertTrue(isValidStrategy('roundrobin')); + }); + + test('isValidStrategy: returns false for invalid strategies', () => { + assertFalse(isValidStrategy('invalid')); + assertFalse(isValidStrategy('')); + assertFalse(isValidStrategy(null)); + assertFalse(isValidStrategy(undefined)); + }); + + test('getStrategyLabel: returns correct labels', () => { + assertEqual(getStrategyLabel('sticky'), 'Sticky (Cache Optimized)'); + assertEqual(getStrategyLabel('round-robin'), 'Round Robin (Load Balanced)'); + assertEqual(getStrategyLabel('roundrobin'), 'Round Robin (Load Balanced)'); + assertEqual(getStrategyLabel('hybrid'), 'Hybrid (Smart Distribution)'); + }); + + test('getStrategyLabel: returns default label for unknown', () => { + assertEqual(getStrategyLabel('unknown'), 'Hybrid (Smart Distribution)'); + assertEqual(getStrategyLabel(null), 'Hybrid (Smart Distribution)'); + }); + + test('STRATEGY_NAMES contains all valid strategies', () => { + assertDeepEqual(STRATEGY_NAMES, ['sticky', 'round-robin', 'hybrid']); + }); + + test('DEFAULT_STRATEGY is hybrid', () => { + assertEqual(DEFAULT_STRATEGY, 'hybrid'); + }); + + // ========================================================================== + // INTEGRATION TESTS + // ========================================================================== + console.log('\n─── Integration Tests ───'); + + test('Integration: Hybrid strategy recovers from rate limits', () => { + const strategy = new HybridStrategy({ + healthScore: { initial: 70, rateLimitPenalty: -10, minUsable: 50 } + }); + const accounts = createMockAccounts(2); + + // Rate limit first account multiple times + for (let i = 0; i < 3; i++) { + strategy.onRateLimit(accounts[0], 'model'); + } + + // Health of first account should be 40 (below minUsable 50) + const healthTracker = strategy.getHealthTracker(); + assertEqual(healthTracker.getScore(accounts[0].email), 40); + assertFalse(healthTracker.isUsable(accounts[0].email)); + + // Selection should prefer second account + const result = strategy.selectAccount(accounts, 'model'); + assertEqual(result.account.email, 'account2@example.com'); + }); + + test('Integration: Token exhaustion triggers last resort fallback', () => { + const strategy = new HybridStrategy({ + tokenBucket: { initialTokens: 2, maxTokens: 10 } + }); + const accounts = createMockAccounts(1); + + // Consume all tokens + strategy.selectAccount(accounts, 'model'); // 2 -> 1 + strategy.selectAccount(accounts, 'model'); // 1 -> 0 + + // Third request should use last resort fallback (not null) + const result = strategy.selectAccount(accounts, 'model'); + assertNotNull(result.account, 'Last resort fallback should return an account'); + // waitMs indicates fallback was used (500ms for lastResort) + assertTrue(result.waitMs >= 500, 'Last resort fallback should add throttle delay'); + }); + + test('Integration: Multi-model rate limiting is independent', () => { + const strategy = new StickyStrategy(); + const accounts = createMockAccounts(2); + + // Rate limit account1 for model-a only + accounts[0].modelRateLimits = { + 'model-a': { isRateLimited: true, resetTime: Date.now() + 300000 } + }; + + // model-a should switch to account2 + const resultA = strategy.selectAccount(accounts, 'model-a', { currentIndex: 0 }); + assertEqual(resultA.account.email, 'account2@example.com'); + + // model-b should still use account1 + const resultB = strategy.selectAccount(accounts, 'model-b', { currentIndex: 0 }); + assertEqual(resultB.account.email, 'account1@example.com'); + }); + + // Summary + console.log('\n' + '═'.repeat(60)); + console.log(`Tests completed: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +}); diff --git a/tests/test-streaming-whitespace.cjs b/tests/test-streaming-whitespace.cjs new file mode 100755 index 0000000000000000000000000000000000000000..51fadacdffc6c88a236cb4fee15c364be423ad94 --- /dev/null +++ b/tests/test-streaming-whitespace.cjs @@ -0,0 +1,119 @@ +/** + * Test Streaming Whitespace - Verifies that whitespace-only chunks are not dropped + * + * Reproduction for Issue #138: "Claude models swallow spaces between words" + */ + +const { TextEncoder } = require('util'); + +// Mock Response class +class MockResponse { + constructor(chunks) { + this.body = { + getReader: () => { + let index = 0; + return { + read: async () => { + if (index >= chunks.length) { + return { done: true, value: undefined }; + } + const chunk = chunks[index++]; + const encoder = new TextEncoder(); + return { done: false, value: encoder.encode(chunk) }; + } + }; + } + }; + } +} + +async function runTests() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ STREAMING WHITESPACE TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Dynamic import for ESM module + const { streamSSEResponse } = await import('../src/cloudcode/sse-streamer.js'); + + let passed = 0; + let failed = 0; + + async function test(name, fn) { + try { + await fn(); + console.log(`✓ ${name}`); + passed++; + } catch (e) { + console.log(`✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } + } + + function assertEqual(actual, expected, message = '') { + if (actual !== expected) { + throw new Error(`${message}\nExpected: "${expected}"\nActual: "${actual}"`); + } + } + + // Test Case: Whitespace preservation + await test('Preserves whitespace-only chunks', async () => { + // Construct chunks that simulate the Google SSE format + // We split "Hello World" into "Hello", " ", "World" + const chunks = [ + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Hello" }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: " " }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "World" }] } }] }) + '\n\n' + ]; + + const response = new MockResponse(chunks); + const originalModel = 'claude-sonnet-4-6'; + + let fullText = ''; + const generator = streamSSEResponse(response, originalModel); + + for await (const event of generator) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + fullText += event.delta.text; + } + } + + assertEqual(fullText, 'Hello World', 'Should preserve space between words'); + }); + + // Test Case: Empty string (should be skipped) + await test('Skips truly empty strings but keeps newlines', async () => { + const chunks = [ + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Line1" }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "" }] } }] }) + '\n\n', // Empty + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "\n" }] } }] }) + '\n\n', // Newline + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Line2" }] } }] }) + '\n\n' + ]; + + const response = new MockResponse(chunks); + const originalModel = 'claude-sonnet-4-6'; + + let fullText = ''; + const generator = streamSSEResponse(response, originalModel); + + for await (const event of generator) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + fullText += event.delta.text; + } + } + + assertEqual(fullText, 'Line1\nLine2', 'Should preserve newline but ignore empty string'); + }); + + console.log('\n' + '═'.repeat(60)); + console.log(`Tests completed: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +}); diff --git a/tests/test-thinking-signatures.cjs b/tests/test-thinking-signatures.cjs new file mode 100755 index 0000000000000000000000000000000000000000..29dfdf3bb5d80f2a7b510eef153e5b34d4e289cb --- /dev/null +++ b/tests/test-thinking-signatures.cjs @@ -0,0 +1,205 @@ +/** + * Thinking Signature Test + * + * Tests that thinking blocks with signatures are properly handled in multi-turn + * conversations, simulating how Claude Code sends requests. + * + * Claude Code sends assistant messages with thinking blocks that include signatures. + * These signatures must be preserved and sent back to the API. + * + * Note: Claude puts signatures on thinking blocks, Gemini 3+ puts them on tool_use blocks. + * + * Runs for both Claude and Gemini model families. + */ +const { streamRequest, analyzeContent, commonTools } = require('./helpers/http-client.cjs'); +const { getTestModels, getModelConfig, familySupportsThinking } = require('./helpers/test-models.cjs'); + +const tools = [commonTools.getWeather]; + +async function runTestsForModel(family, model) { + console.log('='.repeat(60)); + console.log(`THINKING SIGNATURE TEST [${family.toUpperCase()}]`); + console.log(`Model: ${model}`); + console.log('Simulates Claude Code multi-turn with thinking blocks'); + console.log('='.repeat(60)); + console.log(''); + + let allPassed = true; + const results = []; + const modelConfig = getModelConfig(family); + const expectThinking = familySupportsThinking(family); + + // ===== TEST 1: First turn - get thinking block with signature ===== + console.log('TEST 1: Initial request with thinking model'); + console.log('-'.repeat(40)); + + const turn1Messages = [ + { role: 'user', content: 'What is the weather in Paris? Use the get_weather tool.' } + ]; + + const turn1Result = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages: turn1Messages + }); + + const content = analyzeContent(turn1Result.content); + + console.log(` Thinking blocks: ${content.thinking.length}`); + console.log(` Tool use blocks: ${content.toolUse.length}`); + console.log(` Text blocks: ${content.text.length}`); + + // Check signatures - Claude puts them on thinking blocks, Gemini on tool_use blocks + console.log(` Thinking signature: ${content.thinkingHasSignature ? 'YES' : 'NO'}`); + console.log(` Tool use signature: ${content.toolUseHasSignature ? 'YES' : 'NO'}`); + console.log(` Has signature (combined): ${content.hasSignature ? 'YES' : 'NO'}`); + + if (content.hasThinking && content.thinking[0].thinking) { + console.log(` Thinking preview: "${content.thinking[0].thinking.substring(0, 80)}..."`); + } + + // For models that support thinking, expect signature (somewhere) + tool use + // Note: Gemini doesn't always produce thinking blocks, but does put signatures on tool_use + // Claude always produces thinking blocks with signatures + const test1Pass = expectThinking + ? (content.hasSignature && content.hasToolUse) // Signature required, thinking optional for Gemini + : (content.hasToolUse || content.hasText); + results.push({ name: 'Turn 1: Thinking + Signature + Tool Use', passed: test1Pass }); + console.log(` Result: ${test1Pass ? 'PASS' : 'FAIL'}`); + if (!test1Pass) allPassed = false; + + // ===== TEST 2: Second turn - send back thinking with signature ===== + console.log('\nTEST 2: Multi-turn with thinking signature in assistant message'); + console.log('-'.repeat(40)); + + if (!content.hasToolUse) { + console.log(' SKIPPED - No tool use in turn 1'); + results.push({ name: 'Turn 2: Multi-turn with signature', passed: false, skipped: true }); + } else { + // Build assistant message with thinking (including signature) - this is how Claude Code sends it + const assistantContent = turn1Result.content; + + // Log what we're sending back + const thinkingInAssistant = assistantContent.find(b => b.type === 'thinking'); + const toolUseInAssistant = assistantContent.find(b => b.type === 'tool_use'); + if (thinkingInAssistant) { + console.log(` Sending thinking with signature: ${(thinkingInAssistant.signature || '').length} chars`); + } + if (toolUseInAssistant && toolUseInAssistant.thoughtSignature) { + console.log(` Sending tool_use with thoughtSignature: ${toolUseInAssistant.thoughtSignature.length} chars`); + } + + const turn2Messages = [ + ...turn1Messages, + { role: 'assistant', content: assistantContent }, + { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: content.toolUse[0].id, + content: 'The weather in Paris is 18°C and sunny.' + }] + } + ]; + + const turn2Result = await streamRequest({ + model, + max_tokens: modelConfig.max_tokens, + stream: true, + tools, + thinking: modelConfig.thinking, + messages: turn2Messages + }); + + const turn2Content = analyzeContent(turn2Result.content); + + console.log(` Thinking blocks: ${turn2Content.thinking.length}`); + console.log(` Text blocks: ${turn2Content.text.length}`); + + // Check for errors + const hasError = turn2Result.events.some(e => e.type === 'error'); + if (hasError) { + const errorEvent = turn2Result.events.find(e => e.type === 'error'); + console.log(` ERROR: ${errorEvent?.data?.error?.message || 'Unknown error'}`); + } + + if (turn2Content.hasThinking && turn2Content.thinking[0].thinking) { + console.log(` Thinking preview: "${turn2Content.thinking[0].thinking.substring(0, 80)}..."`); + } + + if (turn2Content.hasText && turn2Content.text[0].text) { + console.log(` Response: "${turn2Content.text[0].text.substring(0, 100)}..."`); + } + + const test2Pass = !hasError && (turn2Content.hasThinking || turn2Content.hasText); + results.push({ name: 'Turn 2: Multi-turn with signature', passed: test2Pass }); + console.log(` Result: ${test2Pass ? 'PASS' : 'FAIL'}`); + if (!test2Pass) allPassed = false; + } + + // ===== TEST 3: Verify signature_delta events in stream ===== + console.log('\nTEST 3: Verify signature events in stream'); + console.log('-'.repeat(40)); + + const signatureDeltas = turn1Result.events.filter( + e => e.type === 'content_block_delta' && e.data?.delta?.type === 'signature_delta' + ); + console.log(` signature_delta events: ${signatureDeltas.length}`); + + if (signatureDeltas.length > 0) { + const totalSigLength = signatureDeltas.reduce((sum, e) => sum + (e.data.delta.signature?.length || 0), 0); + console.log(` Total signature length from deltas: ${totalSigLength} chars`); + } + + // For Claude: signature_delta events should be present + // For Gemini: signature is attached to tool_use block directly, may not have signature_delta events + const test3Pass = expectThinking + ? (signatureDeltas.length > 0 || content.toolUseHasSignature) + : true; + results.push({ name: 'Signature present (delta or on tool_use)', passed: test3Pass }); + console.log(` Result: ${test3Pass ? 'PASS' : 'FAIL'}`); + if (!test3Pass) allPassed = false; + + // ===== Summary ===== + console.log('\n' + '='.repeat(60)); + console.log(`SUMMARY [${family.toUpperCase()}]`); + console.log('='.repeat(60)); + + for (const result of results) { + const status = result.skipped ? 'SKIP' : (result.passed ? 'PASS' : 'FAIL'); + console.log(` [${status}] ${result.name}`); + } + + console.log('\n' + '='.repeat(60)); + console.log(`[${family.toUpperCase()}] ${allPassed ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'}`); + console.log('='.repeat(60)); + + return allPassed; +} + +async function runTests() { + const models = await getTestModels(); + let allPassed = true; + + for (const { family, model } of models) { + console.log('\n'); + const passed = await runTestsForModel(family, model); + if (!passed) allPassed = false; + } + + console.log('\n' + '='.repeat(60)); + console.log('FINAL RESULT'); + console.log('='.repeat(60)); + console.log(`Overall: ${allPassed ? 'ALL MODEL FAMILIES PASSED' : 'SOME MODEL FAMILIES FAILED'}`); + console.log('='.repeat(60)); + + process.exit(allPassed ? 0 : 1); +} + +runTests().catch(err => { + console.error('Test failed with error:', err); + process.exit(1); +}); diff --git a/tests/test-version-detection.js b/tests/test-version-detection.js new file mode 100755 index 0000000000000000000000000000000000000000..aef9ccd19a34f12dae36f5accc6eb4188c152019 --- /dev/null +++ b/tests/test-version-detection.js @@ -0,0 +1,27 @@ +import { generateSmartUserAgent } from '../src/utils/version-detector.js'; +import { getPlatformUserAgent } from '../src/constants.js'; +import assert from 'assert'; + +async function testVersionDetection() { + console.log('--- Testing Version Detection ---'); + + // 1. Check User-Agent generation + const ua = generateSmartUserAgent(); + console.log('Generated User-Agent:', ua); + + assert.ok(ua.startsWith('antigravity/'), 'UA should start with antigravity/'); + assert.ok(/\d+\.\d+\.\d+/.test(ua), 'UA should contain a version number'); + + // 2. Check integration in constants.js + const constantsUA = getPlatformUserAgent(); + console.log('Constants User-Agent:', constantsUA); + assert.strictEqual(ua, constantsUA, 'Constants UA should match generated UA'); + + console.log('\n✓ Version detection tests passed!'); +} + +testVersionDetection().catch(err => { + console.error('\n✗ Version detection tests failed:'); + console.error(err); + process.exit(1); +});